From 4a0a18dc7357b63c8716f3e05ea58dd2c573d7ec Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Sun, 22 Mar 2026 20:38:59 -0400 Subject: [PATCH 1/8] feat: DH-22062: add create_global_state and create_user_state shared state hooks Add factory functions for creating shared state that synchronizes across all components using the same store: - create_global_state: shared across all users - create_user_state: scoped per effective user (Enterprise), falls back to anonymous single store on Community Includes unit tests (13 tests), e2e test app + Playwright specs, and documentation with multiple examples. --- plans/global-user-state.md | 80 ++++ plugins/ui/docs/hooks/create_global_state.md | 172 ++++++++ plugins/ui/docs/hooks/create_user_state.md | 169 ++++++++ plugins/ui/docs/hooks/overview.md | 7 + plugins/ui/src/deephaven/ui/hooks/__init__.py | 3 + .../deephaven/ui/hooks/use_shared_state.py | 207 +++++++++ .../deephaven/ui/hooks/test_shared_state.py | 400 ++++++++++++++++++ tests/app.d/tests.app | 3 +- tests/app.d/ui_shared_state.py | 22 + tests/ui_shared_state.spec.ts | 48 +++ 10 files changed, 1110 insertions(+), 1 deletion(-) create mode 100644 plans/global-user-state.md create mode 100644 plugins/ui/docs/hooks/create_global_state.md create mode 100644 plugins/ui/docs/hooks/create_user_state.md create mode 100644 plugins/ui/src/deephaven/ui/hooks/use_shared_state.py create mode 100644 plugins/ui/test/deephaven/ui/hooks/test_shared_state.py create mode 100644 tests/app.d/ui_shared_state.py create mode 100644 tests/ui_shared_state.spec.ts diff --git a/plans/global-user-state.md b/plans/global-user-state.md new file mode 100644 index 000000000..16579e7bc --- /dev/null +++ b/plans/global-user-state.md @@ -0,0 +1,80 @@ +# Plan for implementing a global user state in Deephaven UI + +Proof of concept (not production): + +```python +from deephaven import empty_table, ui + + +def create_store(initial_value=None): + store_value = initial_value + set_values = set() + + def use_store(): + value, set_value = ui.use_state(store_value) + + def add_set_value(): + set_values.add(set_value) + + # Clean up the set values when the component unmounts + return lambda: set_values.discard(set_value) + + ui.use_effect(add_set_value, [set_value]) + + def do_set_value(new_value): + nonlocal store_value + store_value = new_value + for set_value in set_values: + set_value(new_value) + + return value, do_set_value + + return use_store + + +use_value_store = create_store(0) + + +@ui.component +def my_slider(): + value, set_value = use_value_store() + return ui.slider(label="Value", value=value, on_change=set_value) + + +@ui.component +def my_table(): + value, set_value = use_value_store() + t = ui.use_memo( + lambda: empty_table(1000).update(["x=i", f"y=x*Math.sin({value})"]), [value] + ) + return t + + +s = my_slider() +t = my_table() +``` + +You can get user context: + +```python +from deephaven_enterprise import auth_context +from deephaven import ui + +query_user = auth_context.get_authenticated_user() +query_effective = auth_context.get_effective_user() + + +@ui.component +def ui_show_auth_context(): + component_user = auth_context.get_authenticated_user() + component_effective = auth_context.get_effective_user() + return [ + ui.heading(f"Query auth user: {query_user}"), + ui.heading(f"Query effective user: {query_effective}"), + ui.heading(f"Component auth user: {component_user}"), + ui.heading(f"Component effective user: {component_effective}"), + ] + + +auth = ui_show_auth_context() +``` diff --git a/plugins/ui/docs/hooks/create_global_state.md b/plugins/ui/docs/hooks/create_global_state.md new file mode 100644 index 000000000..28ba72c50 --- /dev/null +++ b/plugins/ui/docs/hooks/create_global_state.md @@ -0,0 +1,172 @@ +# create_global_state + +`create_global_state` is a factory function that creates a shared state hook. Unlike `use_state`, which creates state local to a single component, the state created by `create_global_state` is shared across all components that call the returned hook. When any component updates the shared state, all other components using the same hook will re-render with the new value. + +Call `create_global_state` at module level (outside of any component) to create a store. Then call the returned hook inside `@ui.component` functions to subscribe to the shared state. + +When all components using a shared store unmount, the state resets to the initial value. + +## Example + +```python +from deephaven import ui + +# Create the shared state at module level +use_shared_counter = ui.create_global_state(0) + + +@ui.component +def ui_counter_controls(): + count, set_count = use_shared_counter() + return ui.flex( + ui.button(f"Count: {count}", on_press=lambda: set_count(count + 1)), + ui.button("Reset", on_press=lambda: set_count(0)), + ) + + +@ui.component +def ui_counter_display(): + count, _ = use_shared_counter() + return ui.text(f"The shared count is: {count}") + + +controls = ui_counter_controls() +display = ui_counter_display() +``` + +In this example, clicking the button in `ui_counter_controls` will update the count displayed in both `ui_counter_controls` and `ui_counter_display`. + +## Recommendations + +1. **Create stores at module level**: Call `create_global_state` at module level, not inside a component. The returned hook is then used inside components. +2. **Naming convention**: Name the returned hook starting with `use_`, e.g. `use_shared_counter = ui.create_global_state(0)`. This makes it clear that it follows hook rules. +3. **Keep state serializable**: As with `use_state`, use simple serializable values (numbers, strings, lists, dicts) when possible for best compatibility. +4. **Prefer `create_user_state` for user-specific data**: If the state should be independent per user (e.g., user preferences or user-specific selections), use [`create_user_state`](create_user_state.md) instead. + +## Using updater functions + +Like `use_state`, the setter function supports updater functions for state that depends on the previous value: + +```python +from deephaven import ui + +use_shared_counter = ui.create_global_state(0) + + +@ui.component +def ui_increment_buttons(): + count, set_count = use_shared_counter() + + def increase_by(n): + for _ in range(n): + set_count(lambda prev: prev + 1) + + return ui.flex( + ui.button("+1", on_press=lambda: increase_by(1)), + ui.button("+10", on_press=lambda: increase_by(10)), + ui.text(f"Count: {count}"), + ) + + +buttons = ui_increment_buttons() +``` + +When an updater function is passed, it is resolved once using the current store value and the resolved value is broadcast to all subscribers. This ensures all components see the same value regardless of timing. + +## Shared filter example + +A common use case is sharing filter criteria across multiple views: + +```python +from deephaven import ui, empty_table + +use_filter_value = ui.create_global_state(50) + +t = empty_table(1000).update(["x = i", "y = Math.sin(i / 10.0) * 100"]) + + +@ui.component +def ui_filter_slider(): + threshold, set_threshold = use_filter_value() + return ui.slider( + label=f"Filter threshold: {threshold}", + value=threshold, + on_change=set_threshold, + min_value=0, + max_value=100, + ) + + +@ui.component +def ui_filtered_table(): + threshold, _ = use_filter_value() + filtered = ui.use_memo(lambda: t.where(f"y > {threshold}"), [threshold]) + return filtered + + +slider = ui_filter_slider() +filtered = ui_filtered_table() +``` + +## Color theme toggler example + +```python +from deephaven import ui + +use_theme = ui.create_global_state("light") + + +@ui.component +def ui_theme_toggle(): + theme, set_theme = use_theme() + return ui.switch( + "Dark mode", + is_selected=theme == "dark", + on_change=lambda is_dark: set_theme("dark" if is_dark else "light"), + ) + + +@ui.component +def ui_themed_card(): + theme, _ = use_theme() + bg = "#1a1a2e" if theme == "dark" else "#ffffff" + fg = "#ffffff" if theme == "dark" else "#000000" + return ui.view( + ui.text(f"Current theme: {theme}"), + background_color=bg, + color=fg, + padding="size-200", + ) + + +toggle = ui_theme_toggle() +card = ui_themed_card() +``` + +## Cleanup behavior + +When all components that subscribe to a shared store unmount (e.g., all panels using the hook are closed), the store automatically resets to the initial value. This prevents stale state from persisting across sessions. + +If at least one subscriber remains active, the state is preserved. + +## Thread safety + +`create_global_state` is thread-safe. Multiple components can safely read and update the shared state concurrently. State updates are serialized internally using a lock. + +## API reference + +```python skip-test +use_hook = ui.create_global_state(initial_value) +``` + +###### Parameters + +| Parameter | Type | Description | +| --------------- | ---- | ----------------------------------------------------------- | +| `initial_value` | `T` | The initial value for the shared state. Defaults to `None`. | + +###### Returns + +| Type | Description | +| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `Callable[[], tuple[T, Callable[[T \| UpdaterFunction[T]], None]]]` | A hook function. When called inside a component, returns a `(value, set_value)` tuple matching `use_state`. | diff --git a/plugins/ui/docs/hooks/create_user_state.md b/plugins/ui/docs/hooks/create_user_state.md new file mode 100644 index 000000000..7f3c24170 --- /dev/null +++ b/plugins/ui/docs/hooks/create_user_state.md @@ -0,0 +1,169 @@ +# create_user_state + +`create_user_state` is a factory function that creates a shared state hook scoped to the current effective user. Like [`create_global_state`](create_global_state.md), the state is shared across all components that call the returned hook — but each user gets their own independent state. When User A updates a value, only User A's components re-render; User B's components remain unaffected. + +Call `create_user_state` at module level (outside of any component) to create a store. Then call the returned hook inside `@ui.component` functions to subscribe. + +When all of a user's components using a shared store unmount, that user's state resets to the initial value. + +## Example + +```python +from deephaven import ui + +# Create user-scoped shared state at module level +use_user_name = ui.create_user_state("") + + +@ui.component +def ui_name_input(): + name, set_name = use_user_name() + return ui.text_field(label="Your name", value=name, on_change=set_name) + + +@ui.component +def ui_greeting(): + name, _ = use_user_name() + return ui.text(f"Hello, {name or 'stranger'}!") + + +name_input = ui_name_input() +greeting = ui_greeting() +``` + +In this example, each user sees their own name. If User A types "Alice", User B still sees "stranger" until they type their own name. + +## Recommendations + +1. **Create stores at module level**: Call `create_user_state` at module level, not inside a component. The returned hook is then used inside components. +2. **Naming convention**: Name the returned hook starting with `use_`, e.g. `use_user_preference = ui.create_user_state(default)`. +3. **Use for user-specific data**: Preferences, selections, UI state that should differ per user. +4. **Use `create_global_state` for shared data**: If you want all users to share the same value (e.g., a global configuration), use [`create_global_state`](create_global_state.md) instead. + +## Community vs. Enterprise + +On **Deephaven Enterprise**, `create_user_state` uses `deephaven_enterprise.auth_context.get_effective_user()` to identify the current user. Each user gets independent state. + +On **Deephaven Community** (where `deephaven_enterprise` is not installed), all callers share a single anonymous state — effectively behaving the same as `create_global_state`. This allows you to write code that works in both environments without modification. + +## User preferences example + +A common use case is per-user UI preferences: + +```python +from deephaven import ui, empty_table + +use_page_size = ui.create_user_state(25) + +t = empty_table(1000).update(["x = i", "y = Math.sin(i / 10.0) * 100"]) + + +@ui.component +def ui_page_size_picker(): + page_size, set_page_size = use_page_size() + return ui.picker( + "10", + "25", + "50", + "100", + label="Rows per page", + selected_key=str(page_size), + on_selection_change=lambda key: set_page_size(int(key)), + ) + + +@ui.component +def ui_paged_table(): + page_size, _ = use_page_size() + page, set_page = ui.use_state(0) + paged = ui.use_memo( + lambda: t.head(page_size * (page + 1)).tail(page_size), + [page_size, page], + ) + return ui.flex( + paged, + ui.flex( + ui.button("Prev", on_press=lambda: set_page(lambda p: max(0, p - 1))), + ui.text(f"Page {page + 1}"), + ui.button("Next", on_press=lambda: set_page(lambda p: p + 1)), + direction="row", + gap="size-100", + ), + direction="column", + ) + + +picker = ui_page_size_picker() +table_view = ui_paged_table() +``` + +## Per-user selection tracking + +```python +from deephaven import ui + +use_selected_items = ui.create_user_state([]) + + +@ui.component +def ui_item_list(): + selected, set_selected = use_selected_items() + + items = ["Alpha", "Beta", "Gamma", "Delta"] + + def toggle_item(item): + if item in selected: + set_selected([i for i in selected if i != item]) + else: + set_selected(selected + [item]) + + return ui.flex( + *[ + ui.checkbox( + item, + is_selected=item in selected, + on_change=lambda _, i=item: toggle_item(i), + ) + for item in items + ], + direction="column", + ) + + +@ui.component +def ui_selection_summary(): + selected, _ = use_selected_items() + if not selected: + return ui.text("No items selected") + return ui.text(f"Selected: {', '.join(selected)}") + + +item_list = ui_item_list() +summary = ui_selection_summary() +``` + +## Cleanup behavior + +When all components for a given user unmount, that user's state resets to the initial value and the internal store for that user is cleaned up. This prevents stale state across sessions and avoids memory leaks when users disconnect. + +## Thread safety + +`create_user_state` is thread-safe. Multiple users' components can safely read and update their state concurrently. + +## API reference + +```python skip-test +use_hook = ui.create_user_state(initial_value) +``` + +###### Parameters + +| Parameter | Type | Description | +| --------------- | ---- | ----------------------------------------------------------------------- | +| `initial_value` | `T` | The initial value for the user-scoped shared state. Defaults to `None`. | + +###### Returns + +| Type | Description | +| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Callable[[], tuple[T, Callable[[T \| UpdaterFunction[T]], None]]]` | A hook function. When called inside a component, returns a `(value, set_value)` tuple matching `use_state`. The state is scoped to the current effective user. | diff --git a/plugins/ui/docs/hooks/overview.md b/plugins/ui/docs/hooks/overview.md index f480e6bd4..c79635685 100644 --- a/plugins/ui/docs/hooks/overview.md +++ b/plugins/ui/docs/hooks/overview.md @@ -63,6 +63,13 @@ _Data_ hooks let you use data from within a Deephaven table in your component. - [`use_cell_data`](use_cell_data.md) lets you use the data of one cell. - [`use_table_listener`](use_table_listener.md) lets you listen to a table for updates. +### Shared state hooks + +_Shared state_ hooks let you create state that is shared across multiple components. Unlike `use_state`, which is local to a single component, shared state updates propagate to all components using the same store. + +- [`create_global_state`](create_global_state.md) creates a shared state hook that is global across all components and all users. +- [`create_user_state`](create_user_state.md) creates a shared state hook scoped to the current effective user. + ## Create custom hooks You can create your own hooks to reuse stateful logic between components. A custom hook is a function whose name starts with `use` and that may call other hooks. For example, let's say you want to create a custom hook that checks whether a table cell is odd. You can create a custom hook called `use_is_cell_odd`: diff --git a/plugins/ui/src/deephaven/ui/hooks/__init__.py b/plugins/ui/src/deephaven/ui/hooks/__init__.py index 2591820a2..2b4ba31d1 100644 --- a/plugins/ui/src/deephaven/ui/hooks/__init__.py +++ b/plugins/ui/src/deephaven/ui/hooks/__init__.py @@ -14,6 +14,7 @@ from .use_execution_context import use_execution_context from .use_liveness_scope import use_liveness_scope from .use_boolean import use_boolean +from .use_shared_state import create_global_state, create_user_state __all__ = [ @@ -33,4 +34,6 @@ "use_execution_context", "use_liveness_scope", "use_boolean", + "create_global_state", + "create_user_state", ] diff --git a/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py b/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py new file mode 100644 index 000000000..7185c1d71 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import logging +import threading +from typing import Any, Callable, Dict, Generic, TypeVar + +from .use_state import use_state +from .use_effect import use_effect +from .use_ref import use_ref +from .._internal import UpdaterFunction + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +class _SharedStore(Generic[T]): + """ + Internal store that manages shared state across multiple component instances. + Each subscriber (component) gets its own `use_state` setter, and when any + subscriber calls `set_value`, the store broadcasts the new value to all subscribers. + + When all subscribers disconnect (unmount), the store resets to its initial value. + """ + + def __init__(self, initial_value: T): + self._initial_value = initial_value + self._value = initial_value + self._subscribers: set[Callable[[T], None]] = set() + self._lock = threading.Lock() + + def use(self) -> tuple[T, Callable[[T | UpdaterFunction[T]], None]]: + """ + Hook to subscribe to this shared store. Must be called inside a `@ui.component`. + + Returns: + A tuple of (current_value, set_value) matching the `use_state` interface. + """ + value, set_value = use_state(self._value) + + # Keep a ref to the latest set_value so the stable subscriber always + # calls the current setter without the subscriber identity changing. + set_value_ref = use_ref(set_value) + set_value_ref.current = set_value + + # Create a stable subscriber function once (persisted in a ref). + # It delegates to whatever set_value_ref.current is at call time. + subscriber_ref: Any = use_ref(None) + if subscriber_ref.current is None: + + def _subscriber(new_value: T) -> None: + set_value_ref.current(new_value) + + subscriber_ref.current = _subscriber + + subscriber = subscriber_ref.current + + def subscribe(): + with self._lock: + self._subscribers.add(subscriber) + + def cleanup(): + with self._lock: + self._subscribers.discard(subscriber) + if len(self._subscribers) == 0: + self._value = self._initial_value + + return cleanup + + # Empty deps — subscribe on mount, cleanup on unmount only. + use_effect(subscribe, []) + + def shared_set_value(new_value: T | UpdaterFunction[T]) -> None: + # Resolve updater functions once to get the concrete value + if callable(new_value): + with self._lock: + new_value = new_value(self._value) + + with self._lock: + self._value = new_value + subscribers = list(self._subscribers) + + # Broadcast outside the lock to avoid deadlocks + for subscriber in subscribers: + subscriber(new_value) + + return value, shared_set_value + + +def create_global_state( + initial_value: T = None, +) -> Callable[[], tuple[T, Callable[[T | UpdaterFunction[T]], None]]]: + """ + Create a shared state hook that is global across all components and all users. + Call this at module level to create a store, then call the returned hook inside + `@ui.component` functions to subscribe. + + When all components using the store unmount, the state resets to `initial_value`. + + Args: + initial_value: The initial value for the shared state. + + Returns: + A hook function that returns a `(value, set_value)` tuple, matching the + `use_state` interface. The value and setter are shared across all components + that call this hook. + + Example:: + + from deephaven import ui + + use_shared_counter = ui.create_global_state(0) + + @ui.component + def ui_counter_display(): + count, set_count = use_shared_counter() + return ui.text(f"Count: {count}") + + @ui.component + def ui_counter_controls(): + count, set_count = use_shared_counter() + return ui.button(f"Increment ({count})", on_press=lambda: set_count(count + 1)) + + display = ui_counter_display() + controls = ui_counter_controls() + """ + store = _SharedStore(initial_value) + return store.use + + +def create_user_state( + initial_value: T = None, +) -> Callable[[], tuple[T, Callable[[T | UpdaterFunction[T]], None]]]: + """ + Create a shared state hook that is scoped to the current effective user. + Each user gets their own independent state. Call this at module level to create + a store, then call the returned hook inside `@ui.component` functions to subscribe. + + When all components for a given user unmount, that user's state resets to `initial_value`. + + On Deephaven Community (without `deephaven_enterprise`), all callers share a single + anonymous store, behaving the same as `create_global_state`. + + Args: + initial_value: The initial value for the shared state. + + Returns: + A hook function that returns a `(value, set_value)` tuple, matching the + `use_state` interface. The value and setter are shared across all components + for the same effective user. + + Example:: + + from deephaven import ui + + use_user_name = ui.create_user_state("") + + @ui.component + def ui_name_input(): + name, set_name = use_user_name() + return ui.text_field(label="Your name", value=name, on_change=set_name) + + @ui.component + def ui_greeting(): + name, set_name = use_user_name() + return ui.text(f"Hello, {name or 'stranger'}!") + + name_input = ui_name_input() + greeting = ui_greeting() + """ + stores: Dict[str, _SharedStore[T]] = {} + stores_lock = threading.Lock() + + def _get_effective_user() -> str: + try: + from deephaven_enterprise import auth_context # type: ignore[import-not-found] + + return auth_context.get_effective_user() + except (ImportError, ModuleNotFoundError): + return "__anonymous__" + + def use_user_state() -> tuple[T, Callable[[T | UpdaterFunction[T]], None]]: + user_key = _get_effective_user() + + with stores_lock: + if user_key not in stores: + stores[user_key] = _SharedStore(initial_value) + store = stores[user_key] + + result = store.use() + + def cleanup_user_store(): + def do_cleanup(): + with stores_lock: + if user_key in stores: + user_store = stores[user_key] + with user_store._lock: + if len(user_store._subscribers) == 0: + del stores[user_key] + + return do_cleanup + + use_effect(cleanup_user_store, [user_key]) + + return result + + return use_user_state diff --git a/plugins/ui/test/deephaven/ui/hooks/test_shared_state.py b/plugins/ui/test/deephaven/ui/hooks/test_shared_state.py new file mode 100644 index 000000000..5fc5933b2 --- /dev/null +++ b/plugins/ui/test/deephaven/ui/hooks/test_shared_state.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import threading +from operator import itemgetter +from unittest.mock import patch, MagicMock + +from ..BaseTest import BaseTestCase +from .render_utils import render_hook + + +class CreateGlobalStateTestCase(BaseTestCase): + def test_initial_value(self): + """Test that create_global_state returns the initial value on first render.""" + from deephaven.ui.hooks import create_global_state + + use_counter = create_global_state(42) + + def _hook(): + return use_counter() + + render_result = render_hook(_hook) + value, set_value = render_result["result"] + self.assertEqual(value, 42) + + def test_initial_value_none(self): + """Test that create_global_state defaults to None when no initial value is given.""" + from deephaven.ui.hooks import create_global_state + + use_store = create_global_state() + + def _hook(): + return use_store() + + render_result = render_hook(_hook) + value, _ = render_result["result"] + self.assertIsNone(value) + + def test_set_value(self): + """Test setting a new value and rerendering.""" + from deephaven.ui.hooks import create_global_state + + use_counter = create_global_state(0) + + def _hook(): + return use_counter() + + render_result = render_hook(_hook) + value, set_value = render_result["result"] + self.assertEqual(value, 0) + + # Set a new value and rerender + set_value(10) + render_result["rerender"]() + value, set_value = render_result["result"] + self.assertEqual(value, 10) + + def test_updater_function(self): + """Test setting state with an updater function.""" + from deephaven.ui.hooks import create_global_state + + use_counter = create_global_state(5) + + def _hook(): + return use_counter() + + render_result = render_hook(_hook) + value, set_value = render_result["result"] + self.assertEqual(value, 5) + + # Use an updater function + set_value(lambda prev: prev + 3) + render_result["rerender"]() + value, set_value = render_result["result"] + self.assertEqual(value, 8) + + def test_shared_across_components(self): + """Test that two render contexts share the same state.""" + from deephaven.ui.hooks import create_global_state + + use_shared = create_global_state(0) + + def _hook(): + return use_shared() + + # Render two independent "components" using the same store + result_a = render_hook(_hook) + result_b = render_hook(_hook) + + val_a, set_a = result_a["result"] + val_b, set_b = result_b["result"] + self.assertEqual(val_a, 0) + self.assertEqual(val_b, 0) + + # Set value from component A + set_a(99) + + # Rerender both + result_a["rerender"]() + result_b["rerender"]() + + val_a, _ = result_a["result"] + val_b, _ = result_b["result"] + self.assertEqual(val_a, 99) + self.assertEqual(val_b, 99) + + def test_set_from_second_component(self): + """Test that setting from the second component updates the first.""" + from deephaven.ui.hooks import create_global_state + + use_shared = create_global_state("hello") + + def _hook(): + return use_shared() + + result_a = render_hook(_hook) + result_b = render_hook(_hook) + + _, set_b = result_b["result"] + + # Set value from component B + set_b("world") + + result_a["rerender"]() + result_b["rerender"]() + + val_a, _ = result_a["result"] + val_b, _ = result_b["result"] + self.assertEqual(val_a, "world") + self.assertEqual(val_b, "world") + + def test_cleanup_resets_value(self): + """Test that unmounting all subscribers resets the store to initial value.""" + from deephaven.ui.hooks import create_global_state + + use_shared = create_global_state(0) + + def _hook(): + return use_shared() + + result_a = render_hook(_hook) + result_b = render_hook(_hook) + + _, set_a = result_a["result"] + set_a(42) + result_a["rerender"]() + result_b["rerender"]() + + val_a, _ = result_a["result"] + self.assertEqual(val_a, 42) + + # Unmount both subscribers + result_a["unmount"]() + result_b["unmount"]() + + # Re-subscribe with a new component - should get initial value + result_c = render_hook(_hook) + val_c, _ = result_c["result"] + self.assertEqual(val_c, 0) + + result_c["unmount"]() + + def test_partial_unmount_preserves_state(self): + """Test that unmounting one subscriber while another is active preserves state.""" + from deephaven.ui.hooks import create_global_state + + use_shared = create_global_state(0) + + def _hook(): + return use_shared() + + result_a = render_hook(_hook) + result_b = render_hook(_hook) + + _, set_a = result_a["result"] + set_a(77) + result_a["rerender"]() + result_b["rerender"]() + + # Unmount only component A + result_a["unmount"]() + + # Component B should still have the value + result_b["rerender"]() + val_b, _ = result_b["result"] + self.assertEqual(val_b, 77) + + # New component should also get the current value + result_c = render_hook(_hook) + val_c, _ = result_c["result"] + self.assertEqual(val_c, 77) + + result_b["unmount"]() + result_c["unmount"]() + + def test_multiple_sets_before_rerender(self): + """Test that multiple set_value calls before rerender result in last value winning.""" + from deephaven.ui.hooks import create_global_state + + use_shared = create_global_state(0) + + def _hook(): + return use_shared() + + result = render_hook(_hook) + _, set_value = result["result"] + + set_value(1) + set_value(2) + set_value(3) + + result["rerender"]() + value, _ = result["result"] + self.assertEqual(value, 3) + + result["unmount"]() + + def test_thread_safety(self): + """Test concurrent set_value calls from multiple threads.""" + from deephaven.ui.hooks import create_global_state + + use_shared = create_global_state(0) + + def _hook(): + return use_shared() + + result = render_hook(_hook) + _, set_value = result["result"] + + errors = [] + + def increment(): + try: + for _ in range(100): + set_value(lambda prev: prev + 1) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=increment) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + self.assertEqual(len(errors), 0) + + result["rerender"]() + value, _ = result["result"] + # 5 threads * 100 increments = 500 + self.assertEqual(value, 500) + + result["unmount"]() + + +class CreateUserStateTestCase(BaseTestCase): + @patch.dict("sys.modules", {"deephaven_enterprise": None}) + def test_fallback_without_enterprise(self): + """Test that create_user_state falls back to anonymous when deephaven_enterprise is not available.""" + from deephaven.ui.hooks import create_user_state + + use_user_val = create_user_state("default") + + def _hook(): + return use_user_val() + + result = render_hook(_hook) + value, set_value = result["result"] + self.assertEqual(value, "default") + + set_value("updated") + result["rerender"]() + value, _ = result["result"] + self.assertEqual(value, "updated") + + result["unmount"]() + + def test_user_isolation(self): + """Test that different users get independent state.""" + from deephaven.ui.hooks import create_user_state + + use_user_val = create_user_state(0) + + mock_auth = MagicMock() + + def _hook(): + return use_user_val() + + # Render as user_a + mock_auth.get_effective_user.return_value = "user_a" + with patch.dict( + "sys.modules", + { + "deephaven_enterprise": MagicMock(), + "deephaven_enterprise.auth_context": mock_auth, + }, + ): + result_a = render_hook(_hook) + + val_a, set_a = result_a["result"] + self.assertEqual(val_a, 0) + + # Render as user_b + mock_auth.get_effective_user.return_value = "user_b" + with patch.dict( + "sys.modules", + { + "deephaven_enterprise": MagicMock(), + "deephaven_enterprise.auth_context": mock_auth, + }, + ): + result_b = render_hook(_hook) + + val_b, set_b = result_b["result"] + self.assertEqual(val_b, 0) + + # Set value for user_a only + mock_auth.get_effective_user.return_value = "user_a" + with patch.dict( + "sys.modules", + { + "deephaven_enterprise": MagicMock(), + "deephaven_enterprise.auth_context": mock_auth, + }, + ): + set_a(42) + result_a["rerender"]() + + val_a, _ = result_a["result"] + self.assertEqual(val_a, 42) + + # user_b should still be 0 + mock_auth.get_effective_user.return_value = "user_b" + with patch.dict( + "sys.modules", + { + "deephaven_enterprise": MagicMock(), + "deephaven_enterprise.auth_context": mock_auth, + }, + ): + result_b["rerender"]() + + val_b, _ = result_b["result"] + self.assertEqual(val_b, 0) + + result_a["unmount"]() + result_b["unmount"]() + + def test_user_cleanup_removes_store(self): + """Test that unmounting all subscribers for a user cleans up that user's store.""" + from deephaven.ui.hooks import create_user_state + + use_user_val = create_user_state(0) + + mock_auth = MagicMock() + + def _hook(): + return use_user_val() + + # Render as user_a and set value + mock_auth.get_effective_user.return_value = "user_a" + with patch.dict( + "sys.modules", + { + "deephaven_enterprise": MagicMock(), + "deephaven_enterprise.auth_context": mock_auth, + }, + ): + result_a = render_hook(_hook) + + _, set_a = result_a["result"] + set_a(99) + mock_auth.get_effective_user.return_value = "user_a" + with patch.dict( + "sys.modules", + { + "deephaven_enterprise": MagicMock(), + "deephaven_enterprise.auth_context": mock_auth, + }, + ): + result_a["rerender"]() + val_a, _ = result_a["result"] + self.assertEqual(val_a, 99) + + # Unmount user_a + result_a["unmount"]() + + # Re-render as user_a - should get initial value since store was cleaned up + mock_auth.get_effective_user.return_value = "user_a" + with patch.dict( + "sys.modules", + { + "deephaven_enterprise": MagicMock(), + "deephaven_enterprise.auth_context": mock_auth, + }, + ): + result_a2 = render_hook(_hook) + + val_a2, _ = result_a2["result"] + self.assertEqual(val_a2, 0) + + result_a2["unmount"]() diff --git a/tests/app.d/tests.app b/tests/app.d/tests.app index 9637ebd2a..98305969c 100644 --- a/tests/app.d/tests.app +++ b/tests/app.d/tests.app @@ -16,4 +16,5 @@ file_9=ui_grid.py file_10=ui_plotly.py file_11=ag_grid.py file_12=theme_demo.py -file_13=ui_nested_dashboard.py \ No newline at end of file +file_13=ui_nested_dashboard.py +file_14=ui_shared_state.py \ No newline at end of file diff --git a/tests/app.d/ui_shared_state.py b/tests/app.d/ui_shared_state.py new file mode 100644 index 000000000..3286dfdd9 --- /dev/null +++ b/tests/app.d/ui_shared_state.py @@ -0,0 +1,22 @@ +from deephaven import ui + +# Global shared counter - all components and users share the same value +use_global_counter = ui.create_global_state(0) + + +@ui.component +def ui_shared_state_component(): + """Single component that uses the shared state hook twice + to demonstrate that state changes sync between hook calls.""" + count, set_count = use_global_counter() + # Second hook call in the same component to demonstrate sharing + count2, _ = use_global_counter() + return ui.flex( + ui.action_button(f"Count: {count}", on_press=lambda _: set_count(count + 1)), + ui.action_button("Reset", on_press=lambda _: set_count(0)), + ui.text(f"Mirror: {count2}"), + direction="column", + ) + + +ui_shared_state = ui_shared_state_component() diff --git a/tests/ui_shared_state.spec.ts b/tests/ui_shared_state.spec.ts new file mode 100644 index 000000000..21c8c268a --- /dev/null +++ b/tests/ui_shared_state.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { openPanel, gotoPage, SELECTORS } from './utils'; + +test('shared state displays initial value', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_shared_state', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + + await expect(panel.getByRole('button', { name: 'Count: 0' })).toBeVisible(); + await expect(panel.getByText('Mirror: 0')).toBeVisible(); +}); + +test('shared state syncs when increment button is clicked', async ({ + page, +}) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_shared_state', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + + // Click increment + await panel.getByRole('button', { name: 'Count: 0' }).click(); + + // Both should show updated count + await expect(panel.getByRole('button', { name: 'Count: 1' })).toBeVisible(); + await expect(panel.getByText('Mirror: 1')).toBeVisible(); + + // Click again + await panel.getByRole('button', { name: 'Count: 1' }).click(); + await expect(panel.getByRole('button', { name: 'Count: 2' })).toBeVisible(); + await expect(panel.getByText('Mirror: 2')).toBeVisible(); +}); + +test('shared state resets correctly', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_shared_state', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + + // Increment then reset + await panel.getByRole('button', { name: 'Count: 0' }).click(); + await expect(panel.getByRole('button', { name: 'Count: 1' })).toBeVisible(); + + await panel.getByRole('button', { name: 'Reset' }).click(); + await expect(panel.getByRole('button', { name: 'Count: 0' })).toBeVisible(); + await expect(panel.getByText('Mirror: 0')).toBeVisible(); +}); From f52788dd7f01f9dd16bfe232615845566fbb2e6d Mon Sep 17 00:00:00 2001 From: mikebender Date: Mon, 23 Mar 2026 17:35:55 -0400 Subject: [PATCH 2/8] docs: add examples and custom hooks sections to create_global_state and create_user_state --- plugins/ui/docs/hooks/create_global_state.md | 77 +++++++++++++++++++- plugins/ui/docs/hooks/create_user_state.md | 77 +++++++++++++++++++- 2 files changed, 147 insertions(+), 7 deletions(-) diff --git a/plugins/ui/docs/hooks/create_global_state.md b/plugins/ui/docs/hooks/create_global_state.md index 28ba72c50..0d1badfeb 100644 --- a/plugins/ui/docs/hooks/create_global_state.md +++ b/plugins/ui/docs/hooks/create_global_state.md @@ -6,7 +6,9 @@ Call `create_global_state` at module level (outside of any component) to create When all components using a shared store unmount, the state resets to the initial value. -## Example +## Examples + +### Basic example ```python from deephaven import ui @@ -43,7 +45,7 @@ In this example, clicking the button in `ui_counter_controls` will update the co 3. **Keep state serializable**: As with `use_state`, use simple serializable values (numbers, strings, lists, dicts) when possible for best compatibility. 4. **Prefer `create_user_state` for user-specific data**: If the state should be independent per user (e.g., user preferences or user-specific selections), use [`create_user_state`](create_user_state.md) instead. -## Using updater functions +### Using updater functions Like `use_state`, the setter function supports updater functions for state that depends on the previous value: @@ -73,7 +75,7 @@ buttons = ui_increment_buttons() When an updater function is passed, it is resolved once using the current store value and the resolved value is broadcast to all subscribers. This ensures all components see the same value regardless of timing. -## Shared filter example +### Shared filter example A common use case is sharing filter criteria across multiple views: @@ -108,7 +110,7 @@ slider = ui_filter_slider() filtered = ui_filtered_table() ``` -## Color theme toggler example +### Color theme toggler example ```python from deephaven import ui @@ -143,6 +145,73 @@ toggle = ui_theme_toggle() card = ui_themed_card() ``` +### Custom hooks + +You can wrap the hook returned by `create_global_state` to build a custom hook with prepackaged behavior: + +```python +from deephaven import ui + +_use_items = ui.create_global_state([]) + + +def use_items(): + """A custom hook that adds convenience methods on top of shared state.""" + items, set_items = _use_items() + + def add(item): + set_items(lambda prev: prev + [item]) + + def clear(): + set_items([]) + + return items, add, clear + + +@ui.component +def ui_item_input(): + text, set_text = ui.use_state("") + items, add, _ = use_items() + + def handle_add(_e): + if text.strip(): + add(text.strip()) + set_text("") + + return ui.flex( + ui.text_field(label="Add item", value=text, on_change=set_text), + ui.action_button(f"Add ({len(items)})", on_press=handle_add), + direction="row", + gap="size-100", + align_items="end", + ) + + +@ui.component +def ui_item_list(): + items, _, clear = use_items() + return ui.flex( + ui.list_view( + *[ui.item(t) for t in items], + aria_label="Items", + selection_mode=None, + ) + if items + else ui.text("No items yet"), + ui.action_button( + "Clear", on_press=lambda _e: clear(), is_disabled=len(items) == 0 + ), + direction="column", + gap="size-100", + ) + + +item_input = ui_item_input() +item_list = ui_item_list() +``` + +Components call `use_items()` and get back `add` and `clear` functions instead of a raw setter. The button label in the input panel shows the count, which updates when items are cleared from the list panel. + ## Cleanup behavior When all components that subscribe to a shared store unmount (e.g., all panels using the hook are closed), the store automatically resets to the initial value. This prevents stale state from persisting across sessions. diff --git a/plugins/ui/docs/hooks/create_user_state.md b/plugins/ui/docs/hooks/create_user_state.md index 7f3c24170..346ae1787 100644 --- a/plugins/ui/docs/hooks/create_user_state.md +++ b/plugins/ui/docs/hooks/create_user_state.md @@ -6,7 +6,9 @@ Call `create_user_state` at module level (outside of any component) to create a When all of a user's components using a shared store unmount, that user's state resets to the initial value. -## Example +## Examples + +### Basic example ```python from deephaven import ui @@ -46,7 +48,7 @@ On **Deephaven Enterprise**, `create_user_state` uses `deephaven_enterprise.auth On **Deephaven Community** (where `deephaven_enterprise` is not installed), all callers share a single anonymous state — effectively behaving the same as `create_global_state`. This allows you to write code that works in both environments without modification. -## User preferences example +### User preferences example A common use case is per-user UI preferences: @@ -97,7 +99,7 @@ picker = ui_page_size_picker() table_view = ui_paged_table() ``` -## Per-user selection tracking +### Per-user selection tracking ```python from deephaven import ui @@ -142,6 +144,75 @@ item_list = ui_item_list() summary = ui_selection_summary() ``` +### Custom hooks + +You can wrap the hook returned by `create_user_state` to build a custom hook with prepackaged behavior: + +```python +from deephaven import ui + +_use_messages = ui.create_user_state([]) + + +def use_messages(): + """A custom hook that adds convenience methods on top of user-scoped state.""" + messages, set_messages = _use_messages() + + def add(text): + set_messages(lambda prev: prev + [text]) + + def clear(): + set_messages([]) + + return messages, add, clear + + +@ui.component +def ui_message_input(): + text, set_text = ui.use_state("") + _, add, _ = use_messages() + + def handle_add(_e): + if text.strip(): + add(text.strip()) + set_text("") + + return ui.flex( + ui.text_field(label="Message", value=text, on_change=set_text), + ui.action_button("Send", on_press=handle_add), + direction="row", + gap="size-100", + align_items="end", + ) + + +@ui.component +def ui_message_list(): + messages, _, clear = use_messages() + return ui.flex( + ui.list_view( + *[ui.item(m) for m in messages], + aria_label="Messages", + selection_mode=None, + ) + if messages + else ui.text("No messages yet"), + ui.action_button( + f"Clear ({len(messages)})", + on_press=lambda _e: clear(), + is_disabled=len(messages) == 0, + ), + direction="column", + gap="size-100", + ) + + +message_input = ui_message_input() +message_list = ui_message_list() +``` + +Components call `use_messages()` and get back `add` and `clear` functions instead of a raw setter. Each user's messages are independent — on Enterprise, User A and User B see different lists. + ## Cleanup behavior When all components for a given user unmount, that user's state resets to the initial value and the internal store for that user is cleaned up. This prevents stale state across sessions and avoids memory leaks when users disconnect. From 716b1d3785d37c4079ba04f01a51a9ee0dc3392f Mon Sep 17 00:00:00 2001 From: mikebender Date: Mon, 23 Mar 2026 17:40:31 -0400 Subject: [PATCH 3/8] Update API reference --- plugins/ui/docs/hooks/create_global_state.md | 18 +++--------------- plugins/ui/docs/hooks/create_user_state.md | 18 +++--------------- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/plugins/ui/docs/hooks/create_global_state.md b/plugins/ui/docs/hooks/create_global_state.md index 0d1badfeb..c8ea5e67e 100644 --- a/plugins/ui/docs/hooks/create_global_state.md +++ b/plugins/ui/docs/hooks/create_global_state.md @@ -222,20 +222,8 @@ If at least one subscriber remains active, the state is preserved. `create_global_state` is thread-safe. Multiple components can safely read and update the shared state concurrently. State updates are serialized internally using a lock. -## API reference +## API Reference -```python skip-test -use_hook = ui.create_global_state(initial_value) +```{eval-rst} +.. dhautofunction:: deephaven.ui.create_global_state ``` - -###### Parameters - -| Parameter | Type | Description | -| --------------- | ---- | ----------------------------------------------------------- | -| `initial_value` | `T` | The initial value for the shared state. Defaults to `None`. | - -###### Returns - -| Type | Description | -| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `Callable[[], tuple[T, Callable[[T \| UpdaterFunction[T]], None]]]` | A hook function. When called inside a component, returns a `(value, set_value)` tuple matching `use_state`. | diff --git a/plugins/ui/docs/hooks/create_user_state.md b/plugins/ui/docs/hooks/create_user_state.md index 346ae1787..ef14cd15a 100644 --- a/plugins/ui/docs/hooks/create_user_state.md +++ b/plugins/ui/docs/hooks/create_user_state.md @@ -221,20 +221,8 @@ When all components for a given user unmount, that user's state resets to the in `create_user_state` is thread-safe. Multiple users' components can safely read and update their state concurrently. -## API reference +## API Reference -```python skip-test -use_hook = ui.create_user_state(initial_value) +```{eval-rst} +.. dhautofunction:: deephaven.ui.create_user_state ``` - -###### Parameters - -| Parameter | Type | Description | -| --------------- | ---- | ----------------------------------------------------------------------- | -| `initial_value` | `T` | The initial value for the user-scoped shared state. Defaults to `None`. | - -###### Returns - -| Type | Description | -| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Callable[[], tuple[T, Callable[[T \| UpdaterFunction[T]], None]]]` | A hook function. When called inside a component, returns a `(value, set_value)` tuple matching `use_state`. The state is scoped to the current effective user. | From e4aa1d139d4b959fa5d578dea086bddd1353e049 Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Mon, 23 Mar 2026 23:42:57 -0400 Subject: [PATCH 4/8] Clean up docs based on review --- plugins/ui/docs/hooks/create_user_state.md | 2 +- plugins/ui/docs/hooks/overview.md | 2 +- plugins/ui/docs/sidebar.json | 8 ++++ .../deephaven/ui/hooks/use_shared_state.py | 38 ------------------- 4 files changed, 10 insertions(+), 40 deletions(-) diff --git a/plugins/ui/docs/hooks/create_user_state.md b/plugins/ui/docs/hooks/create_user_state.md index ef14cd15a..70e095d8a 100644 --- a/plugins/ui/docs/hooks/create_user_state.md +++ b/plugins/ui/docs/hooks/create_user_state.md @@ -1,6 +1,6 @@ # create_user_state -`create_user_state` is a factory function that creates a shared state hook scoped to the current effective user. Like [`create_global_state`](create_global_state.md), the state is shared across all components that call the returned hook — but each user gets their own independent state. When User A updates a value, only User A's components re-render; User B's components remain unaffected. +`create_user_state` creates a shared state hook scoped to the current effective user. Like [`create_global_state`](create_global_state.md), the state is shared across all components that call the returned hook — but each user gets their own independent state. When User A updates a value, only User A's components re-render; User B's components remain unaffected. Call `create_user_state` at module level (outside of any component) to create a store. Then call the returned hook inside `@ui.component` functions to subscribe. diff --git a/plugins/ui/docs/hooks/overview.md b/plugins/ui/docs/hooks/overview.md index c79635685..0b1ae8a82 100644 --- a/plugins/ui/docs/hooks/overview.md +++ b/plugins/ui/docs/hooks/overview.md @@ -67,8 +67,8 @@ _Data_ hooks let you use data from within a Deephaven table in your component. _Shared state_ hooks let you create state that is shared across multiple components. Unlike `use_state`, which is local to a single component, shared state updates propagate to all components using the same store. -- [`create_global_state`](create_global_state.md) creates a shared state hook that is global across all components and all users. - [`create_user_state`](create_user_state.md) creates a shared state hook scoped to the current effective user. +- [`create_global_state`](create_global_state.md) creates a shared state hook that is global across all components and all users. ## Create custom hooks diff --git a/plugins/ui/docs/sidebar.json b/plugins/ui/docs/sidebar.json index 0c40e0db1..c3f872975 100644 --- a/plugins/ui/docs/sidebar.json +++ b/plugins/ui/docs/sidebar.json @@ -423,6 +423,14 @@ "label": "Overview", "path": "hooks/overview.md" }, + { + "label": "create_global_state", + "path": "hooks/create_global_state.md" + }, + { + "label": "create_user_state", + "path": "hooks/create_user_state.md" + }, { "label": "use_boolean", "path": "hooks/use_boolean.md" diff --git a/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py b/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py index 7185c1d71..36a1d7afd 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py @@ -104,25 +104,6 @@ def create_global_state( A hook function that returns a `(value, set_value)` tuple, matching the `use_state` interface. The value and setter are shared across all components that call this hook. - - Example:: - - from deephaven import ui - - use_shared_counter = ui.create_global_state(0) - - @ui.component - def ui_counter_display(): - count, set_count = use_shared_counter() - return ui.text(f"Count: {count}") - - @ui.component - def ui_counter_controls(): - count, set_count = use_shared_counter() - return ui.button(f"Increment ({count})", on_press=lambda: set_count(count + 1)) - - display = ui_counter_display() - controls = ui_counter_controls() """ store = _SharedStore(initial_value) return store.use @@ -148,25 +129,6 @@ def create_user_state( A hook function that returns a `(value, set_value)` tuple, matching the `use_state` interface. The value and setter are shared across all components for the same effective user. - - Example:: - - from deephaven import ui - - use_user_name = ui.create_user_state("") - - @ui.component - def ui_name_input(): - name, set_name = use_user_name() - return ui.text_field(label="Your name", value=name, on_change=set_name) - - @ui.component - def ui_greeting(): - name, set_name = use_user_name() - return ui.text(f"Hello, {name or 'stranger'}!") - - name_input = ui_name_input() - greeting = ui_greeting() """ stores: Dict[str, _SharedStore[T]] = {} stores_lock = threading.Lock() From dbe40a5c9b94a107ea047df6b3eee4aabb9292e0 Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Tue, 24 Mar 2026 00:22:24 -0400 Subject: [PATCH 5/8] Update doc snapshots --- plugins/ui/docs/hooks/create_global_state.md | 3 +-- .../ui/docs/snapshots/27edc70d4aa23b97f1163c9691620930.json | 1 + .../ui/docs/snapshots/2b3474e98617339c07fc6b0341170ed3.json | 1 + .../ui/docs/snapshots/434bfc77988a792b36d57f3779a2f172.json | 1 + .../ui/docs/snapshots/5d8a278c20fd9c6491d953ace6afa623.json | 1 + .../ui/docs/snapshots/5fed81ab05e6efe3a3ba367048db14ca.json | 1 + .../ui/docs/snapshots/61201adfb6026e816771b582e556301d.json | 1 + .../ui/docs/snapshots/8ced86e833c43d3855045d361245b519.json | 1 + .../ui/docs/snapshots/b5a09535fcf542f7dcc918d763715657.json | 1 + .../ui/docs/snapshots/b5ecd538cd0f352b352608e2c33ac622.json | 1 + 10 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 plugins/ui/docs/snapshots/27edc70d4aa23b97f1163c9691620930.json create mode 100644 plugins/ui/docs/snapshots/2b3474e98617339c07fc6b0341170ed3.json create mode 100644 plugins/ui/docs/snapshots/434bfc77988a792b36d57f3779a2f172.json create mode 100644 plugins/ui/docs/snapshots/5d8a278c20fd9c6491d953ace6afa623.json create mode 100644 plugins/ui/docs/snapshots/5fed81ab05e6efe3a3ba367048db14ca.json create mode 100644 plugins/ui/docs/snapshots/61201adfb6026e816771b582e556301d.json create mode 100644 plugins/ui/docs/snapshots/8ced86e833c43d3855045d361245b519.json create mode 100644 plugins/ui/docs/snapshots/b5a09535fcf542f7dcc918d763715657.json create mode 100644 plugins/ui/docs/snapshots/b5ecd538cd0f352b352608e2c33ac622.json diff --git a/plugins/ui/docs/hooks/create_global_state.md b/plugins/ui/docs/hooks/create_global_state.md index c8ea5e67e..07d72849e 100644 --- a/plugins/ui/docs/hooks/create_global_state.md +++ b/plugins/ui/docs/hooks/create_global_state.md @@ -134,9 +134,8 @@ def ui_themed_card(): bg = "#1a1a2e" if theme == "dark" else "#ffffff" fg = "#ffffff" if theme == "dark" else "#000000" return ui.view( - ui.text(f"Current theme: {theme}"), + ui.text(f"Current theme: {theme}", color=fg), background_color=bg, - color=fg, padding="size-200", ) diff --git a/plugins/ui/docs/snapshots/27edc70d4aa23b97f1163c9691620930.json b/plugins/ui/docs/snapshots/27edc70d4aa23b97f1163c9691620930.json new file mode 100644 index 000000000..3f22db88a --- /dev/null +++ b/plugins/ui/docs/snapshots/27edc70d4aa23b97f1163c9691620930.json @@ -0,0 +1 @@ +{"file":"hooks/create_user_state.md","objects":{"t":{"type":"Table","data":{"columns":[{"name":"x","type":"int"},{"name":"y","type":"double"}],"rows":[[{"value":"0"},{"value":"0.0000"}],[{"value":"1"},{"value":"9.9833"}],[{"value":"2"},{"value":"19.8669"}],[{"value":"3"},{"value":"29.5520"}],[{"value":"4"},{"value":"38.9418"}],[{"value":"5"},{"value":"47.9426"}],[{"value":"6"},{"value":"56.4642"}],[{"value":"7"},{"value":"64.4218"}],[{"value":"8"},{"value":"71.7356"}],[{"value":"9"},{"value":"78.3327"}],[{"value":"10"},{"value":"84.1471"}],[{"value":"11"},{"value":"89.1207"}],[{"value":"12"},{"value":"93.2039"}],[{"value":"13"},{"value":"96.3558"}],[{"value":"14"},{"value":"98.5450"}],[{"value":"15"},{"value":"99.7495"}],[{"value":"16"},{"value":"99.9574"}],[{"value":"17"},{"value":"99.1665"}],[{"value":"18"},{"value":"97.3848"}],[{"value":"19"},{"value":"94.6300"}],[{"value":"20"},{"value":"90.9297"}],[{"value":"21"},{"value":"86.3209"}],[{"value":"22"},{"value":"80.8496"}],[{"value":"23"},{"value":"74.5705"}],[{"value":"24"},{"value":"67.5463"}],[{"value":"25"},{"value":"59.8472"}],[{"value":"26"},{"value":"51.5501"}],[{"value":"27"},{"value":"42.7380"}],[{"value":"28"},{"value":"33.4988"}],[{"value":"29"},{"value":"23.9249"}],[{"value":"30"},{"value":"14.1120"}],[{"value":"31"},{"value":"4.1581"}],[{"value":"32"},{"value":"-5.8374"}],[{"value":"33"},{"value":"-15.7746"}],[{"value":"34"},{"value":"-25.5541"}],[{"value":"35"},{"value":"-35.0783"}],[{"value":"36"},{"value":"-44.2520"}],[{"value":"37"},{"value":"-52.9836"}],[{"value":"38"},{"value":"-61.1858"}],[{"value":"39"},{"value":"-68.7766"}],[{"value":"40"},{"value":"-75.6802"}],[{"value":"41"},{"value":"-81.8277"}],[{"value":"42"},{"value":"-87.1576"}],[{"value":"43"},{"value":"-91.6166"}],[{"value":"44"},{"value":"-95.1602"}],[{"value":"45"},{"value":"-97.7530"}],[{"value":"46"},{"value":"-99.3691"}],[{"value":"47"},{"value":"-99.9923"}],[{"value":"48"},{"value":"-99.6165"}],[{"value":"49"},{"value":"-98.2453"}],[{"value":"50"},{"value":"-95.8924"}],[{"value":"51"},{"value":"-92.5815"}],[{"value":"52"},{"value":"-88.3455"}],[{"value":"53"},{"value":"-83.2267"}],[{"value":"54"},{"value":"-77.2764"}],[{"value":"55"},{"value":"-70.5540"}],[{"value":"56"},{"value":"-63.1267"}],[{"value":"57"},{"value":"-55.0686"}],[{"value":"58"},{"value":"-46.4602"}],[{"value":"59"},{"value":"-37.3877"}],[{"value":"60"},{"value":"-27.9415"}],[{"value":"61"},{"value":"-18.2163"}],[{"value":"62"},{"value":"-8.3089"}],[{"value":"63"},{"value":"1.6814"}],[{"value":"64"},{"value":"11.6549"}],[{"value":"65"},{"value":"21.5120"}],[{"value":"66"},{"value":"31.1541"}],[{"value":"67"},{"value":"40.4850"}],[{"value":"68"},{"value":"49.4113"}],[{"value":"69"},{"value":"57.8440"}],[{"value":"70"},{"value":"65.6987"}],[{"value":"71"},{"value":"72.8969"}],[{"value":"72"},{"value":"79.3668"}],[{"value":"73"},{"value":"85.0437"}],[{"value":"74"},{"value":"89.8708"}],[{"value":"75"},{"value":"93.8000"}],[{"value":"76"},{"value":"96.7920"}],[{"value":"77"},{"value":"98.8168"}],[{"value":"78"},{"value":"99.8543"}],[{"value":"79"},{"value":"99.8941"}],[{"value":"80"},{"value":"98.9358"}],[{"value":"81"},{"value":"96.9890"}],[{"value":"82"},{"value":"94.0731"}],[{"value":"83"},{"value":"90.2172"}],[{"value":"84"},{"value":"85.4599"}],[{"value":"85"},{"value":"79.8487"}],[{"value":"86"},{"value":"73.4397"}],[{"value":"87"},{"value":"66.2969"}],[{"value":"88"},{"value":"58.4917"}],[{"value":"89"},{"value":"50.1021"}],[{"value":"90"},{"value":"41.2118"}],[{"value":"91"},{"value":"31.9098"}],[{"value":"92"},{"value":"22.2890"}],[{"value":"93"},{"value":"12.4454"}],[{"value":"94"},{"value":"2.4775"}],[{"value":"95"},{"value":"-7.5151"}],[{"value":"96"},{"value":"-17.4327"}],[{"value":"97"},{"value":"-27.1761"}],[{"value":"98"},{"value":"-36.6479"}],[{"value":"99"},{"value":"-45.7536"}]]}},"picker":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Picker","props":{"selectedKey":"25","onSelectionChange":{"__dhCbid":"cb0"},"align":"start","direction":"bottom","shouldFlip":true,"label":"Rows per page","labelPosition":"top","children":["10","25","50","100"]}}},"__dhElemName":"__main__.ui_page_size_picker"},"state":"{\"state\": {\"0\": 25}}"}},"table_view":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhObid":0},{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"row","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Prev"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Page 1"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb1"},"children":"Next"}}]}}]}}},"__dhElemName":"__main__.ui_paged_table"},"state":"{\"state\": {\"0\": 25, \"15\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/2b3474e98617339c07fc6b0341170ed3.json b/plugins/ui/docs/snapshots/2b3474e98617339c07fc6b0341170ed3.json new file mode 100644 index 000000000..db0cbfc93 --- /dev/null +++ b/plugins/ui/docs/snapshots/2b3474e98617339c07fc6b0341170ed3.json @@ -0,0 +1 @@ +{"file":"hooks/create_global_state.md","objects":{"controls":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"Count: 0"}},{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb1"},"children":"Reset"}}]}}},"__dhElemName":"__main__.ui_counter_controls"},"state":"{\"state\": {\"0\": 0}}"}},"display":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["The shared count is: 0"],"slot":"text"}}},"__dhElemName":"__main__.ui_counter_display"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/434bfc77988a792b36d57f3779a2f172.json b/plugins/ui/docs/snapshots/434bfc77988a792b36d57f3779a2f172.json new file mode 100644 index 000000000..e11bd1c5c --- /dev/null +++ b/plugins/ui/docs/snapshots/434bfc77988a792b36d57f3779a2f172.json @@ -0,0 +1 @@ +{"file":"hooks/create_user_state.md","objects":{"item_list":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Checkbox","props":{"children":["Alpha"],"isSelected":false,"onChange":{"__dhCbid":"cb0"}}},{"__dhElemName":"deephaven.ui.components.Checkbox","props":{"children":["Beta"],"isSelected":false,"onChange":{"__dhCbid":"cb1"}}},{"__dhElemName":"deephaven.ui.components.Checkbox","props":{"children":["Gamma"],"isSelected":false,"onChange":{"__dhCbid":"cb2"}}},{"__dhElemName":"deephaven.ui.components.Checkbox","props":{"children":["Delta"],"isSelected":false,"onChange":{"__dhCbid":"cb3"}}}]}}},"__dhElemName":"__main__.ui_item_list"},"state":"{}"}},"summary":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["No items selected"],"slot":"text"}}},"__dhElemName":"__main__.ui_selection_summary"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/5d8a278c20fd9c6491d953ace6afa623.json b/plugins/ui/docs/snapshots/5d8a278c20fd9c6491d953ace6afa623.json new file mode 100644 index 000000000..ec3acf317 --- /dev/null +++ b/plugins/ui/docs/snapshots/5d8a278c20fd9c6491d953ace6afa623.json @@ -0,0 +1 @@ +{"file":"hooks/create_global_state.md","objects":{"t":{"type":"Table","data":{"columns":[{"name":"x","type":"int"},{"name":"y","type":"double"}],"rows":[[{"value":"0"},{"value":"0.0000"}],[{"value":"1"},{"value":"9.9833"}],[{"value":"2"},{"value":"19.8669"}],[{"value":"3"},{"value":"29.5520"}],[{"value":"4"},{"value":"38.9418"}],[{"value":"5"},{"value":"47.9426"}],[{"value":"6"},{"value":"56.4642"}],[{"value":"7"},{"value":"64.4218"}],[{"value":"8"},{"value":"71.7356"}],[{"value":"9"},{"value":"78.3327"}],[{"value":"10"},{"value":"84.1471"}],[{"value":"11"},{"value":"89.1207"}],[{"value":"12"},{"value":"93.2039"}],[{"value":"13"},{"value":"96.3558"}],[{"value":"14"},{"value":"98.5450"}],[{"value":"15"},{"value":"99.7495"}],[{"value":"16"},{"value":"99.9574"}],[{"value":"17"},{"value":"99.1665"}],[{"value":"18"},{"value":"97.3848"}],[{"value":"19"},{"value":"94.6300"}],[{"value":"20"},{"value":"90.9297"}],[{"value":"21"},{"value":"86.3209"}],[{"value":"22"},{"value":"80.8496"}],[{"value":"23"},{"value":"74.5705"}],[{"value":"24"},{"value":"67.5463"}],[{"value":"25"},{"value":"59.8472"}],[{"value":"26"},{"value":"51.5501"}],[{"value":"27"},{"value":"42.7380"}],[{"value":"28"},{"value":"33.4988"}],[{"value":"29"},{"value":"23.9249"}],[{"value":"30"},{"value":"14.1120"}],[{"value":"31"},{"value":"4.1581"}],[{"value":"32"},{"value":"-5.8374"}],[{"value":"33"},{"value":"-15.7746"}],[{"value":"34"},{"value":"-25.5541"}],[{"value":"35"},{"value":"-35.0783"}],[{"value":"36"},{"value":"-44.2520"}],[{"value":"37"},{"value":"-52.9836"}],[{"value":"38"},{"value":"-61.1858"}],[{"value":"39"},{"value":"-68.7766"}],[{"value":"40"},{"value":"-75.6802"}],[{"value":"41"},{"value":"-81.8277"}],[{"value":"42"},{"value":"-87.1576"}],[{"value":"43"},{"value":"-91.6166"}],[{"value":"44"},{"value":"-95.1602"}],[{"value":"45"},{"value":"-97.7530"}],[{"value":"46"},{"value":"-99.3691"}],[{"value":"47"},{"value":"-99.9923"}],[{"value":"48"},{"value":"-99.6165"}],[{"value":"49"},{"value":"-98.2453"}],[{"value":"50"},{"value":"-95.8924"}],[{"value":"51"},{"value":"-92.5815"}],[{"value":"52"},{"value":"-88.3455"}],[{"value":"53"},{"value":"-83.2267"}],[{"value":"54"},{"value":"-77.2764"}],[{"value":"55"},{"value":"-70.5540"}],[{"value":"56"},{"value":"-63.1267"}],[{"value":"57"},{"value":"-55.0686"}],[{"value":"58"},{"value":"-46.4602"}],[{"value":"59"},{"value":"-37.3877"}],[{"value":"60"},{"value":"-27.9415"}],[{"value":"61"},{"value":"-18.2163"}],[{"value":"62"},{"value":"-8.3089"}],[{"value":"63"},{"value":"1.6814"}],[{"value":"64"},{"value":"11.6549"}],[{"value":"65"},{"value":"21.5120"}],[{"value":"66"},{"value":"31.1541"}],[{"value":"67"},{"value":"40.4850"}],[{"value":"68"},{"value":"49.4113"}],[{"value":"69"},{"value":"57.8440"}],[{"value":"70"},{"value":"65.6987"}],[{"value":"71"},{"value":"72.8969"}],[{"value":"72"},{"value":"79.3668"}],[{"value":"73"},{"value":"85.0437"}],[{"value":"74"},{"value":"89.8708"}],[{"value":"75"},{"value":"93.8000"}],[{"value":"76"},{"value":"96.7920"}],[{"value":"77"},{"value":"98.8168"}],[{"value":"78"},{"value":"99.8543"}],[{"value":"79"},{"value":"99.8941"}],[{"value":"80"},{"value":"98.9358"}],[{"value":"81"},{"value":"96.9890"}],[{"value":"82"},{"value":"94.0731"}],[{"value":"83"},{"value":"90.2172"}],[{"value":"84"},{"value":"85.4599"}],[{"value":"85"},{"value":"79.8487"}],[{"value":"86"},{"value":"73.4397"}],[{"value":"87"},{"value":"66.2969"}],[{"value":"88"},{"value":"58.4917"}],[{"value":"89"},{"value":"50.1021"}],[{"value":"90"},{"value":"41.2118"}],[{"value":"91"},{"value":"31.9098"}],[{"value":"92"},{"value":"22.2890"}],[{"value":"93"},{"value":"12.4454"}],[{"value":"94"},{"value":"2.4775"}],[{"value":"95"},{"value":"-7.5151"}],[{"value":"96"},{"value":"-17.4327"}],[{"value":"97"},{"value":"-27.1761"}],[{"value":"98"},{"value":"-36.6479"}],[{"value":"99"},{"value":"-45.7536"}]]}},"slider":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Slider","props":{"labelPosition":"top","orientation":"horizontal","minValue":0,"maxValue":100,"step":1,"value":50,"label":"Filter threshold: 50","onChange":{"__dhCbid":"cb0"}}}},"__dhElemName":"__main__.ui_filter_slider"},"state":"{\"state\": {\"0\": 50}}"}},"filtered":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhObid":0}},"__dhElemName":"__main__.ui_filtered_table"},"state":"{\"state\": {\"0\": 50}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/5fed81ab05e6efe3a3ba367048db14ca.json b/plugins/ui/docs/snapshots/5fed81ab05e6efe3a3ba367048db14ca.json new file mode 100644 index 000000000..e28c9cf6e --- /dev/null +++ b/plugins/ui/docs/snapshots/5fed81ab05e6efe3a3ba367048db14ca.json @@ -0,0 +1 @@ +{"file":"hooks/create_global_state.md","objects":{"toggle":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Switch","props":{"isSelected":false,"onChange":{"__dhCbid":"cb0"},"children":"Dark mode"}}},"__dhElemName":"__main__.ui_theme_toggle"},"state":"{\"state\": {\"0\": \"light\"}}"}},"card":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.View","props":{"children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Current theme: light"],"color":"#000000","slot":"text"}}],"padding":"size-200","backgroundColor":"#ffffff"}}},"__dhElemName":"__main__.ui_themed_card"},"state":"{\"state\": {\"0\": \"light\"}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/61201adfb6026e816771b582e556301d.json b/plugins/ui/docs/snapshots/61201adfb6026e816771b582e556301d.json new file mode 100644 index 000000000..e65d82e50 --- /dev/null +++ b/plugins/ui/docs/snapshots/61201adfb6026e816771b582e556301d.json @@ -0,0 +1 @@ +{"file":"hooks/create_user_state.md","objects":{"name_input":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.TextField","props":{"value":"","label":"Your name","labelPosition":"top","onChange":{"__dhCbid":"cb0"}}}},"__dhElemName":"__main__.ui_name_input"},"state":"{\"state\": {\"0\": \"\"}}"}},"greeting":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Hello, stranger!"],"slot":"text"}}},"__dhElemName":"__main__.ui_greeting"},"state":"{\"state\": {\"0\": \"\"}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/8ced86e833c43d3855045d361245b519.json b/plugins/ui/docs/snapshots/8ced86e833c43d3855045d361245b519.json new file mode 100644 index 000000000..6033690cd --- /dev/null +++ b/plugins/ui/docs/snapshots/8ced86e833c43d3855045d361245b519.json @@ -0,0 +1 @@ +{"file":"hooks/create_global_state.md","objects":{"buttons":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb0"},"children":"+1"}},{"__dhElemName":"deephaven.ui.components.Button","props":{"variant":"accent","style":"fill","type":"button","onPress":{"__dhCbid":"cb1"},"children":"+10"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Count: 0"],"slot":"text"}}]}}},"__dhElemName":"__main__.ui_increment_buttons"},"state":"{\"state\": {\"0\": 0}}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/b5a09535fcf542f7dcc918d763715657.json b/plugins/ui/docs/snapshots/b5a09535fcf542f7dcc918d763715657.json new file mode 100644 index 000000000..5c9f144a6 --- /dev/null +++ b/plugins/ui/docs/snapshots/b5a09535fcf542f7dcc918d763715657.json @@ -0,0 +1 @@ +{"file":"hooks/create_user_state.md","objects":{"message_input":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"row","alignItems":"end","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.TextField","props":{"value":"","label":"Message","labelPosition":"top","onChange":{"__dhCbid":"cb0"}}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb1"},"children":"Send"}}]}}},"__dhElemName":"__main__.ui_message_input"},"state":"{\"state\": {\"0\": \"\"}}"}},"message_list":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["No messages yet"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb0"},"isDisabled":true,"children":"Clear (0)"}}]}}},"__dhElemName":"__main__.ui_message_list"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/b5ecd538cd0f352b352608e2c33ac622.json b/plugins/ui/docs/snapshots/b5ecd538cd0f352b352608e2c33ac622.json new file mode 100644 index 000000000..ac713abce --- /dev/null +++ b/plugins/ui/docs/snapshots/b5ecd538cd0f352b352608e2c33ac622.json @@ -0,0 +1 @@ +{"file":"hooks/create_global_state.md","objects":{"item_input":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"row","alignItems":"end","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.TextField","props":{"value":"","label":"Add item","labelPosition":"top","onChange":{"__dhCbid":"cb0"}}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb1"},"children":"Add (0)"}}]}}},"__dhElemName":"__main__.ui_item_input"},"state":"{\"state\": {\"0\": \"\"}}"}},"item_list":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["No items yet"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb0"},"isDisabled":true,"children":"Clear"}}]}}},"__dhElemName":"__main__.ui_item_list"},"state":"{}"}}}} \ No newline at end of file From f20c6bc08ec7191d332411c56a7a36f0ff8e097f Mon Sep 17 00:00:00 2001 From: mikebender Date: Tue, 24 Mar 2026 10:45:23 -0400 Subject: [PATCH 6/8] feat: support callable initializer for create_global_state and create_user_state Allow passing a callable as initial_value, invoked once at store creation time (matching the useState lazy-initializer pattern). Add unit and e2e tests for the new behavior. --- .../deephaven/ui/hooks/use_shared_state.py | 19 ++- .../deephaven/ui/hooks/test_shared_state.py | 153 ++++++++++++++++++ tests/app.d/ui_shared_state.py | 11 ++ tests/ui_shared_state.spec.ts | 45 ++++++ 4 files changed, 221 insertions(+), 7 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py b/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py index 36a1d7afd..19f78e468 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py @@ -23,9 +23,10 @@ class _SharedStore(Generic[T]): When all subscribers disconnect (unmount), the store resets to its initial value. """ - def __init__(self, initial_value: T): - self._initial_value = initial_value - self._value = initial_value + def __init__(self, initial_value: T | Callable[[], T]): + resolved = initial_value() if callable(initial_value) else initial_value + self._initial_value = resolved + self._value = resolved self._subscribers: set[Callable[[T], None]] = set() self._lock = threading.Lock() @@ -88,7 +89,7 @@ def shared_set_value(new_value: T | UpdaterFunction[T]) -> None: def create_global_state( - initial_value: T = None, + initial_value: T | Callable[[], T] = None, ) -> Callable[[], tuple[T, Callable[[T | UpdaterFunction[T]], None]]]: """ Create a shared state hook that is global across all components and all users. @@ -98,7 +99,9 @@ def create_global_state( When all components using the store unmount, the state resets to `initial_value`. Args: - initial_value: The initial value for the shared state. + initial_value: The initial value for the shared state, or a callable that + returns the initial value. If a callable is provided, it will be invoked + once when the store is created. Returns: A hook function that returns a `(value, set_value)` tuple, matching the @@ -110,7 +113,7 @@ def create_global_state( def create_user_state( - initial_value: T = None, + initial_value: T | Callable[[], T] = None, ) -> Callable[[], tuple[T, Callable[[T | UpdaterFunction[T]], None]]]: """ Create a shared state hook that is scoped to the current effective user. @@ -123,7 +126,9 @@ def create_user_state( anonymous store, behaving the same as `create_global_state`. Args: - initial_value: The initial value for the shared state. + initial_value: The initial value for the shared state, or a callable that + returns the initial value. If a callable is provided, it will be invoked + once per user when their store is created. Returns: A hook function that returns a `(value, set_value)` tuple, matching the diff --git a/plugins/ui/test/deephaven/ui/hooks/test_shared_state.py b/plugins/ui/test/deephaven/ui/hooks/test_shared_state.py index 5fc5933b2..58db8af04 100644 --- a/plugins/ui/test/deephaven/ui/hooks/test_shared_state.py +++ b/plugins/ui/test/deephaven/ui/hooks/test_shared_state.py @@ -22,6 +22,81 @@ def _hook(): value, set_value = render_result["result"] self.assertEqual(value, 42) + def test_initial_value_callable(self): + """Test that create_global_state accepts a callable as initial value.""" + from deephaven.ui.hooks import create_global_state + + use_store = create_global_state(lambda: {"a": 1, "b": 2}) + + def _hook(): + return use_store() + + render_result = render_hook(_hook) + value, _ = render_result["result"] + self.assertEqual(value, {"a": 1, "b": 2}) + + render_result["unmount"]() + + def test_initial_value_callable_called_once(self): + """Test that a callable initial value is only invoked once at store creation.""" + from deephaven.ui.hooks import create_global_state + + call_count = 0 + + def initializer(): + nonlocal call_count + call_count += 1 + return 42 + + use_store = create_global_state(initializer) + self.assertEqual(call_count, 1) + + def _hook(): + return use_store() + + # Multiple renders should not call the initializer again + result_a = render_hook(_hook) + result_b = render_hook(_hook) + self.assertEqual(call_count, 1) + + result_a["unmount"]() + result_b["unmount"]() + + def test_initial_value_callable_reset(self): + """Test that after all subscribers unmount, re-subscribing uses the already-resolved initial value.""" + from deephaven.ui.hooks import create_global_state + + call_count = 0 + + def initializer(): + nonlocal call_count + call_count += 1 + return 100 + + use_store = create_global_state(initializer) + self.assertEqual(call_count, 1) + + def _hook(): + return use_store() + + result = render_hook(_hook) + _, set_value = result["result"] + set_value(999) + result["rerender"]() + value, _ = result["result"] + self.assertEqual(value, 999) + + # Unmount all subscribers + result["unmount"]() + + # Re-subscribe - should get the resolved initial value (100), not re-call initializer + result2 = render_hook(_hook) + value2, _ = result2["result"] + self.assertEqual(value2, 100) + self.assertEqual(call_count, 1) # Still only called once + + result2["unmount"]() + def test_initial_value_none(self): """Test that create_global_state defaults to None when no initial value is given.""" from deephaven.ui.hooks import create_global_state @@ -252,6 +327,84 @@ def increment(): class CreateUserStateTestCase(BaseTestCase): + @patch.dict("sys.modules", {"deephaven_enterprise": None}) + def test_callable_initial_value(self): + """Test that create_user_state accepts a callable as initial value.""" + from deephaven.ui.hooks import create_user_state + + call_count = 0 + + def initializer(): + nonlocal call_count + call_count += 1 + return [1, 2, 3] + + use_user_val = create_user_state(initializer) + # Not called yet — user stores are created lazily + self.assertEqual(call_count, 0) + + def _hook(): + return use_user_val() + + result = render_hook(_hook) + value, _ = result["result"] + self.assertEqual(value, [1, 2, 3]) + self.assertEqual(call_count, 1) + + result["unmount"]() + + def test_callable_initial_value_per_user(self): + """Test that a callable initial value is invoked once per user.""" + from deephaven.ui.hooks import create_user_state + + call_count = 0 + + def initializer(): + nonlocal call_count + call_count += 1 + return {"data": []} + + use_user_val = create_user_state(initializer) + + mock_auth = MagicMock() + + def _hook(): + return use_user_val() + + # Render as user_a + mock_auth.get_effective_user.return_value = "user_a" + with patch.dict( + "sys.modules", + { + "deephaven_enterprise": MagicMock(), + "deephaven_enterprise.auth_context": mock_auth, + }, + ): + result_a = render_hook(_hook) + self.assertEqual(call_count, 1) + + # Render as user_b + mock_auth.get_effective_user.return_value = "user_b" + with patch.dict( + "sys.modules", + { + "deephaven_enterprise": MagicMock(), + "deephaven_enterprise.auth_context": mock_auth, + }, + ): + result_b = render_hook(_hook) + self.assertEqual(call_count, 2) + + val_a, _ = result_a["result"] + val_b, _ = result_b["result"] + # Each user should get an independent copy + self.assertEqual(val_a, {"data": []}) + self.assertEqual(val_b, {"data": []}) + self.assertIsNot(val_a, val_b) # Different objects + + result_a["unmount"]() + result_b["unmount"]() + @patch.dict("sys.modules", {"deephaven_enterprise": None}) def test_fallback_without_enterprise(self): """Test that create_user_state falls back to anonymous when deephaven_enterprise is not available.""" diff --git a/tests/app.d/ui_shared_state.py b/tests/app.d/ui_shared_state.py index 3286dfdd9..33bf816f5 100644 --- a/tests/app.d/ui_shared_state.py +++ b/tests/app.d/ui_shared_state.py @@ -3,6 +3,9 @@ # Global shared counter - all components and users share the same value use_global_counter = ui.create_global_state(0) +# Global shared state using a callable initializer +use_global_list = ui.create_global_state(lambda: ["initial"]) + @ui.component def ui_shared_state_component(): @@ -11,10 +14,18 @@ def ui_shared_state_component(): count, set_count = use_global_counter() # Second hook call in the same component to demonstrate sharing count2, _ = use_global_counter() + + items, set_items = use_global_list() + return ui.flex( ui.action_button(f"Count: {count}", on_press=lambda _: set_count(count + 1)), ui.action_button("Reset", on_press=lambda _: set_count(0)), ui.text(f"Mirror: {count2}"), + ui.text(f"List: {', '.join(items)}"), + ui.action_button( + "Add item", + on_press=lambda _: set_items(lambda prev: prev + [f"item{len(prev)}"]), + ), direction="column", ) diff --git a/tests/ui_shared_state.spec.ts b/tests/ui_shared_state.spec.ts index 21c8c268a..db385001c 100644 --- a/tests/ui_shared_state.spec.ts +++ b/tests/ui_shared_state.spec.ts @@ -1,12 +1,18 @@ import { expect, test } from '@playwright/test'; import { openPanel, gotoPage, SELECTORS } from './utils'; +// Tests must run serially within each browser project because they share +// global server-side state. Each test resets to a known state first to +// handle cross-browser interference from parallel projects. + test('shared state displays initial value', async ({ page }) => { await gotoPage(page, ''); await openPanel(page, 'ui_shared_state', SELECTORS.REACT_PANEL_VISIBLE); const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + // Reset to known state (other browser projects may have changed it) + await panel.getByRole('button', { name: 'Reset' }).click(); await expect(panel.getByRole('button', { name: 'Count: 0' })).toBeVisible(); await expect(panel.getByText('Mirror: 0')).toBeVisible(); }); @@ -19,6 +25,10 @@ test('shared state syncs when increment button is clicked', async ({ const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + // Reset to known state + await panel.getByRole('button', { name: 'Reset' }).click(); + await expect(panel.getByRole('button', { name: 'Count: 0' })).toBeVisible(); + // Click increment await panel.getByRole('button', { name: 'Count: 0' }).click(); @@ -38,6 +48,10 @@ test('shared state resets correctly', async ({ page }) => { const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + // Reset to known state + await panel.getByRole('button', { name: 'Reset' }).click(); + await expect(panel.getByRole('button', { name: 'Count: 0' })).toBeVisible(); + // Increment then reset await panel.getByRole('button', { name: 'Count: 0' }).click(); await expect(panel.getByRole('button', { name: 'Count: 1' })).toBeVisible(); @@ -46,3 +60,34 @@ test('shared state resets correctly', async ({ page }) => { await expect(panel.getByRole('button', { name: 'Count: 0' })).toBeVisible(); await expect(panel.getByText('Mirror: 0')).toBeVisible(); }); + +test('shared state with callable initializer displays initial value', async ({ + page, +}) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_shared_state', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + + // The callable initializer returns ["initial"], so text always starts with "initial" + await expect(panel.getByText('List: initial')).toBeVisible(); +}); + +test('shared state with callable initializer updates correctly', async ({ + page, +}) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_shared_state', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + + // Get the current list text before clicking + const listText = panel.getByText('List: '); + const textBefore = await listText.textContent(); + + // Click "Add item" to append to the list + await panel.getByRole('button', { name: 'Add item' }).click(); + + // Verify the list now has more content than before + await expect(listText).not.toHaveText(textBefore!); +}); From 4678ccecc7daac0961c9c66d9935cc2b33f802b1 Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Wed, 1 Apr 2026 22:12:34 -0400 Subject: [PATCH 7/8] refactor: move ValueWithLiveness to utils.py, reset shared state on unmount - Move ValueWithLiveness and _value_or_call (renamed to value_or_call) from RenderContext.py to utils.py - Reset shared store value to None when all subscribers unmount to release resources - Re-resolve initial value (re-calling callable if provided) on first new subscribe after reset - Update test to expect initializer re-invocation after full unmount/reset --- .../deephaven/ui/_internal/RenderContext.py | 33 +------------ .../ui/src/deephaven/ui/_internal/__init__.py | 3 +- .../ui/src/deephaven/ui/_internal/utils.py | 46 ++++++++++++++++++- .../deephaven/ui/hooks/use_shared_state.py | 21 ++++++--- .../deephaven/ui/hooks/test_shared_state.py | 6 +-- 5 files changed, 65 insertions(+), 44 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py index cb8773d55..37b64dce5 100644 --- a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py +++ b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py @@ -10,17 +10,15 @@ Optional, Tuple, TypeVar, - Union, Generator, - Generic, cast, ) from functools import partial from deephaven import DHError from deephaven.liveness_scope import LivenessScope from contextlib import contextmanager -from dataclasses import dataclass from .NoContextException import NoContextException +from .utils import ValueWithLiveness, value_or_call as _value_or_call logger = logging.getLogger(__name__) @@ -72,14 +70,6 @@ """ -@dataclass -class ValueWithLiveness(Generic[T]): - """A value with an associated liveness scope, if any.""" - - value: T - liveness_scope: Union[LivenessScope, None] - - ContextState = Dict[StateKey, ValueWithLiveness[Any]] """ The state for a context. @@ -91,27 +81,6 @@ class ValueWithLiveness(Generic[T]): """ -def _value_or_call( - value: T | None | Callable[[], T | None] -) -> ValueWithLiveness[T | None]: - """ - Creates a wrapper around the value, or invokes a callable to hold the value and the liveness scope - creates while obtaining that value. - - Args: - value: a value, or callable that will produce a value - - Returns: - The resulting value, plus a liveness scope, if any. - """ - if callable(value): - scope = LivenessScope() - with scope.open(): - value = value() - return ValueWithLiveness(value=value, liveness_scope=scope) - return ValueWithLiveness(value=value, liveness_scope=None) - - def _should_retain_value(value: ValueWithLiveness[Any]) -> bool: """ Determine if the given value should be retained by the current context. diff --git a/plugins/ui/src/deephaven/ui/_internal/__init__.py b/plugins/ui/src/deephaven/ui/_internal/__init__.py index c5e6cc59a..09bc3c4ab 100644 --- a/plugins/ui/src/deephaven/ui/_internal/__init__.py +++ b/plugins/ui/src/deephaven/ui/_internal/__init__.py @@ -12,10 +12,11 @@ UpdaterFunction, get_context, NoContextException, - ValueWithLiveness, ExportedRenderState, ) from .utils import ( + ValueWithLiveness, + value_or_call, get_component_name, get_component_qualname, to_camel_case, diff --git a/plugins/ui/src/deephaven/ui/_internal/utils.py b/plugins/ui/src/deephaven/ui/_internal/utils.py index c88f8fdde..230485516 100644 --- a/plugins/ui/src/deephaven/ui/_internal/utils.py +++ b/plugins/ui/src/deephaven/ui/_internal/utils.py @@ -1,5 +1,17 @@ from __future__ import annotations -from typing import Any, Callable, Dict, List, Set, Tuple, cast, Sequence, TypeVar, Union +from typing import ( + Any, + Callable, + Dict, + Generic, + List, + Set, + Tuple, + cast, + Sequence, + TypeVar, + Union, +) from deephaven.dtypes import ( Instant as DTypeInstant, ZonedDateTime as DTypeZonedDateTime, @@ -7,7 +19,9 @@ ) from inspect import signature import sys +from dataclasses import dataclass from functools import partial +from deephaven.liveness_scope import LivenessScope from deephaven.time import ( to_j_instant, to_j_zdt, @@ -31,6 +45,36 @@ T = TypeVar("T") + +@dataclass +class ValueWithLiveness(Generic[T]): + """A value with an associated liveness scope, if any.""" + + value: T + liveness_scope: Union[LivenessScope, None] + + +def value_or_call( + value: T | None | Callable[[], T | None] +) -> ValueWithLiveness[T | None]: + """ + Creates a wrapper around the value, or invokes a callable to hold the value and the liveness scope + creates while obtaining that value. + + Args: + value: a value, or callable that will produce a value + + Returns: + The resulting value, plus a liveness scope, if any. + """ + if callable(value): + scope = LivenessScope() + with scope.open(): + value = value() + return ValueWithLiveness(value=value, liveness_scope=scope) + return ValueWithLiveness(value=value, liveness_scope=None) + + _UNSAFE_PREFIX = "UNSAFE_" _ARIA_PREFIX = "aria_" _ARIA_PREFIX_REPLACEMENT = "aria-" diff --git a/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py b/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py index 19f78e468..a66c5cfe2 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_shared_state.py @@ -2,12 +2,13 @@ import logging import threading -from typing import Any, Callable, Dict, Generic, TypeVar +from typing import Any, Callable, Dict, Generic, TypeVar, cast from .use_state import use_state from .use_effect import use_effect from .use_ref import use_ref from .._internal import UpdaterFunction +from .._internal.utils import value_or_call logger = logging.getLogger(__name__) @@ -24,9 +25,8 @@ class _SharedStore(Generic[T]): """ def __init__(self, initial_value: T | Callable[[], T]): - resolved = initial_value() if callable(initial_value) else initial_value - self._initial_value = resolved - self._value = resolved + self._initial_value_or_callable = initial_value + self._value: T | None = value_or_call(initial_value).value self._subscribers: set[Callable[[T], None]] = set() self._lock = threading.Lock() @@ -37,7 +37,14 @@ def use(self) -> tuple[T, Callable[[T | UpdaterFunction[T]], None]]: Returns: A tuple of (current_value, set_value) matching the `use_state` interface. """ - value, set_value = use_state(self._value) + # Resolve the current value; if None (reset after last unmount), + # re-initialize from the original initial_value. + with self._lock: + if self._value is None and len(self._subscribers) == 0: + self._value = value_or_call(self._initial_value_or_callable).value + init = cast(T, self._value) + + value, set_value = use_state(init) # Keep a ref to the latest set_value so the stable subscriber always # calls the current setter without the subscriber identity changing. @@ -64,7 +71,7 @@ def cleanup(): with self._lock: self._subscribers.discard(subscriber) if len(self._subscribers) == 0: - self._value = self._initial_value + self._value = None return cleanup @@ -75,7 +82,7 @@ def shared_set_value(new_value: T | UpdaterFunction[T]) -> None: # Resolve updater functions once to get the concrete value if callable(new_value): with self._lock: - new_value = new_value(self._value) + new_value = new_value(cast(T, self._value)) with self._lock: self._value = new_value diff --git a/plugins/ui/test/deephaven/ui/hooks/test_shared_state.py b/plugins/ui/test/deephaven/ui/hooks/test_shared_state.py index 58db8af04..b9705f77d 100644 --- a/plugins/ui/test/deephaven/ui/hooks/test_shared_state.py +++ b/plugins/ui/test/deephaven/ui/hooks/test_shared_state.py @@ -63,7 +63,7 @@ def _hook(): result_b["unmount"]() def test_initial_value_callable_reset(self): - """Test that after all subscribers unmount, re-subscribing uses the already-resolved initial value.""" + """Test that after all subscribers unmount, re-subscribing re-calls the initializer.""" from deephaven.ui.hooks import create_global_state call_count = 0 @@ -89,11 +89,11 @@ def _hook(): # Unmount all subscribers result["unmount"]() - # Re-subscribe - should get the resolved initial value (100), not re-call initializer + # Re-subscribe - should get the initial value (100) and re-call the initializer result2 = render_hook(_hook) value2, _ = result2["result"] self.assertEqual(value2, 100) - self.assertEqual(call_count, 1) # Still only called once + self.assertEqual(call_count, 2) # Called again after reset result2["unmount"]() From c2c6e2d8f6286c872e21e6bd19ad72730a652363 Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Wed, 1 Apr 2026 22:13:06 -0400 Subject: [PATCH 8/8] Update docs based on self-review --- plans/global-user-state.md | 80 -------------------- plugins/ui/docs/hooks/create_global_state.md | 39 +--------- plugins/ui/docs/hooks/create_user_state.md | 78 +++---------------- 3 files changed, 11 insertions(+), 186 deletions(-) delete mode 100644 plans/global-user-state.md diff --git a/plans/global-user-state.md b/plans/global-user-state.md deleted file mode 100644 index 16579e7bc..000000000 --- a/plans/global-user-state.md +++ /dev/null @@ -1,80 +0,0 @@ -# Plan for implementing a global user state in Deephaven UI - -Proof of concept (not production): - -```python -from deephaven import empty_table, ui - - -def create_store(initial_value=None): - store_value = initial_value - set_values = set() - - def use_store(): - value, set_value = ui.use_state(store_value) - - def add_set_value(): - set_values.add(set_value) - - # Clean up the set values when the component unmounts - return lambda: set_values.discard(set_value) - - ui.use_effect(add_set_value, [set_value]) - - def do_set_value(new_value): - nonlocal store_value - store_value = new_value - for set_value in set_values: - set_value(new_value) - - return value, do_set_value - - return use_store - - -use_value_store = create_store(0) - - -@ui.component -def my_slider(): - value, set_value = use_value_store() - return ui.slider(label="Value", value=value, on_change=set_value) - - -@ui.component -def my_table(): - value, set_value = use_value_store() - t = ui.use_memo( - lambda: empty_table(1000).update(["x=i", f"y=x*Math.sin({value})"]), [value] - ) - return t - - -s = my_slider() -t = my_table() -``` - -You can get user context: - -```python -from deephaven_enterprise import auth_context -from deephaven import ui - -query_user = auth_context.get_authenticated_user() -query_effective = auth_context.get_effective_user() - - -@ui.component -def ui_show_auth_context(): - component_user = auth_context.get_authenticated_user() - component_effective = auth_context.get_effective_user() - return [ - ui.heading(f"Query auth user: {query_user}"), - ui.heading(f"Query effective user: {query_effective}"), - ui.heading(f"Component auth user: {component_user}"), - ui.heading(f"Component effective user: {component_effective}"), - ] - - -auth = ui_show_auth_context() -``` diff --git a/plugins/ui/docs/hooks/create_global_state.md b/plugins/ui/docs/hooks/create_global_state.md index 07d72849e..321e24aab 100644 --- a/plugins/ui/docs/hooks/create_global_state.md +++ b/plugins/ui/docs/hooks/create_global_state.md @@ -42,8 +42,7 @@ In this example, clicking the button in `ui_counter_controls` will update the co 1. **Create stores at module level**: Call `create_global_state` at module level, not inside a component. The returned hook is then used inside components. 2. **Naming convention**: Name the returned hook starting with `use_`, e.g. `use_shared_counter = ui.create_global_state(0)`. This makes it clear that it follows hook rules. -3. **Keep state serializable**: As with `use_state`, use simple serializable values (numbers, strings, lists, dicts) when possible for best compatibility. -4. **Prefer `create_user_state` for user-specific data**: If the state should be independent per user (e.g., user preferences or user-specific selections), use [`create_user_state`](create_user_state.md) instead. +3. **Prefer `create_user_state` for user-specific data**: If the state should be independent per user (e.g., user preferences or user-specific selections), use [`create_user_state`](create_user_state.md) instead. ### Using updater functions @@ -110,40 +109,6 @@ slider = ui_filter_slider() filtered = ui_filtered_table() ``` -### Color theme toggler example - -```python -from deephaven import ui - -use_theme = ui.create_global_state("light") - - -@ui.component -def ui_theme_toggle(): - theme, set_theme = use_theme() - return ui.switch( - "Dark mode", - is_selected=theme == "dark", - on_change=lambda is_dark: set_theme("dark" if is_dark else "light"), - ) - - -@ui.component -def ui_themed_card(): - theme, _ = use_theme() - bg = "#1a1a2e" if theme == "dark" else "#ffffff" - fg = "#ffffff" if theme == "dark" else "#000000" - return ui.view( - ui.text(f"Current theme: {theme}", color=fg), - background_color=bg, - padding="size-200", - ) - - -toggle = ui_theme_toggle() -card = ui_themed_card() -``` - ### Custom hooks You can wrap the hook returned by `create_global_state` to build a custom hook with prepackaged behavior: @@ -213,7 +178,7 @@ Components call `use_items()` and get back `add` and `clear` functions instead o ## Cleanup behavior -When all components that subscribe to a shared store unmount (e.g., all panels using the hook are closed), the store automatically resets to the initial value. This prevents stale state from persisting across sessions. +When all components that subscribe to a shared store unmount (e.g., all panels using the hook are closed), the store is released. This prevents memory leaks from unused state. When a new component later subscribes to the same store, it will be recreated with the initial value. If at least one subscriber remains active, the state is preserved. diff --git a/plugins/ui/docs/hooks/create_user_state.md b/plugins/ui/docs/hooks/create_user_state.md index 70e095d8a..cfdd4d15f 100644 --- a/plugins/ui/docs/hooks/create_user_state.md +++ b/plugins/ui/docs/hooks/create_user_state.md @@ -48,57 +48,6 @@ On **Deephaven Enterprise**, `create_user_state` uses `deephaven_enterprise.auth On **Deephaven Community** (where `deephaven_enterprise` is not installed), all callers share a single anonymous state — effectively behaving the same as `create_global_state`. This allows you to write code that works in both environments without modification. -### User preferences example - -A common use case is per-user UI preferences: - -```python -from deephaven import ui, empty_table - -use_page_size = ui.create_user_state(25) - -t = empty_table(1000).update(["x = i", "y = Math.sin(i / 10.0) * 100"]) - - -@ui.component -def ui_page_size_picker(): - page_size, set_page_size = use_page_size() - return ui.picker( - "10", - "25", - "50", - "100", - label="Rows per page", - selected_key=str(page_size), - on_selection_change=lambda key: set_page_size(int(key)), - ) - - -@ui.component -def ui_paged_table(): - page_size, _ = use_page_size() - page, set_page = ui.use_state(0) - paged = ui.use_memo( - lambda: t.head(page_size * (page + 1)).tail(page_size), - [page_size, page], - ) - return ui.flex( - paged, - ui.flex( - ui.button("Prev", on_press=lambda: set_page(lambda p: max(0, p - 1))), - ui.text(f"Page {page + 1}"), - ui.button("Next", on_press=lambda: set_page(lambda p: p + 1)), - direction="row", - gap="size-100", - ), - direction="column", - ) - - -picker = ui_page_size_picker() -table_view = ui_paged_table() -``` - ### Per-user selection tracking ```python @@ -111,24 +60,15 @@ use_selected_items = ui.create_user_state([]) def ui_item_list(): selected, set_selected = use_selected_items() - items = ["Alpha", "Beta", "Gamma", "Delta"] - - def toggle_item(item): - if item in selected: - set_selected([i for i in selected if i != item]) - else: - set_selected(selected + [item]) - - return ui.flex( - *[ - ui.checkbox( - item, - is_selected=item in selected, - on_change=lambda _, i=item: toggle_item(i), - ) - for item in items - ], - direction="column", + return ui.list_view( + ui.item("Alpha"), + ui.item("Beta"), + ui.item("Gamma"), + ui.item("Delta"), + aria_label="Items", + selection_mode="MULTIPLE", + selected_keys=selected, + on_change=lambda keys: set_selected(list(keys)), )