Skip to content

feat(clientdata): id is now optional on clientdata.output_*() methods #1978

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* `ui.update_*()` functions now accept `ui.TagChild` (i.e., HTML) as input to the `label` and `icon` arguments. (#2020)

* The `.output_*()` methods of the `ClientData` class (e.g., `session.clientdata.output_height()`) can now be called without an `id` when called inside a `@render` function. (#1978)

* `playwright.controller.InputActionButton` gains a `expect_icon()` method. As a result, the already existing `expect_label()` no longer includes the icon. (#2020)

### Improvements
Expand All @@ -25,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* Added `timeout_secs` parameter to `create_app_fixture` to allow testing apps with longer startup times. (#2033)

* Added module support for `session.clientdata` methods. This allows you to access client data values in Shiny modules without needing to namespace the keys explicitly. (#1978)

### Bug fixes

* Fixed an issue with `ui.Chat()` sometimes wanting to scroll a parent element. (#1996)
Expand All @@ -38,7 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [1.4.0] - 2025-04-08

## New features
### New features

* Added support for bookmarking Shiny applications. Bookmarking allows users to save the current state of an application and return to it later. This feature is available in both Shiny Core and Shiny Express. (#1870, #1915, #1919, #1920, #1922, #1934, #1938, #1945, #1955)
* To enable bookmarking in Express mode, set `shiny.express.app_opts(bookmark_store=)` during the app's initial construction.
Expand Down
63 changes: 50 additions & 13 deletions shiny/session/_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
AsyncIterable,
Awaitable,
Callable,
Generator,
Iterable,
Literal,
Optional,
Expand Down Expand Up @@ -540,6 +541,7 @@ def __init__(

self.user: str | None = None
self.groups: list[str] | None = None

credentials_json: str = ""
if "shiny-server-credentials" in self.http_conn.headers:
credentials_json = self.http_conn.headers["shiny-server-credentials"]
Expand Down Expand Up @@ -1218,6 +1220,7 @@ def __init__(self, root_session: Session, ns: ResolvedId) -> None:
ns=ns,
outputs=root_session.output._outputs,
)
self.clientdata = ClientData(self)
self._outbound_message_queues = root_session._outbound_message_queues
self._downloads = root_session._downloads

Expand Down Expand Up @@ -1507,6 +1510,7 @@ class ClientData:

def __init__(self, session: Session) -> None:
self._session: Session = session
self._current_output_name: ResolvedId | None = None

def url_hash(self) -> str:
"""
Expand Down Expand Up @@ -1556,7 +1560,7 @@ def pixelratio(self) -> float:
"""
return cast(int, self._read_input("pixelratio"))

def output_height(self, id: str) -> float | None:
def output_height(self, id: Optional[Id] = None) -> float | None:
"""
Reactively read the height of an output.

Expand All @@ -1573,7 +1577,7 @@ def output_height(self, id: str) -> float | None:
"""
return cast(float, self._read_output(id, "height"))

def output_width(self, id: str) -> float | None:
def output_width(self, id: Optional[Id] = None) -> float | None:
"""
Reactively read the width of an output.

Expand All @@ -1590,7 +1594,7 @@ def output_width(self, id: str) -> float | None:
"""
return cast(float, self._read_output(id, "width"))

def output_hidden(self, id: str) -> bool | None:
def output_hidden(self, id: Optional[Id] = None) -> bool | None:
"""
Reactively read whether an output is hidden.

Expand All @@ -1606,7 +1610,7 @@ def output_hidden(self, id: str) -> bool | None:
"""
return cast(bool, self._read_output(id, "hidden"))

def output_bg_color(self, id: str) -> str | None:
def output_bg_color(self, id: Optional[Id] = None) -> str | None:
"""
Reactively read the background color of an output.

Expand All @@ -1623,7 +1627,7 @@ def output_bg_color(self, id: str) -> str | None:
"""
return cast(str, self._read_output(id, "bg"))

def output_fg_color(self, id: str) -> str | None:
def output_fg_color(self, id: Optional[Id] = None) -> str | None:
"""
Reactively read the foreground color of an output.

Expand All @@ -1640,7 +1644,7 @@ def output_fg_color(self, id: str) -> str | None:
"""
return cast(str, self._read_output(id, "fg"))

def output_accent_color(self, id: str) -> str | None:
def output_accent_color(self, id: Optional[Id] = None) -> str | None:
"""
Reactively read the accent color of an output.

Expand All @@ -1657,7 +1661,7 @@ def output_accent_color(self, id: str) -> str | None:
"""
return cast(str, self._read_output(id, "accent"))

def output_font(self, id: str) -> str | None:
def output_font(self, id: Optional[Id] = None) -> str | None:
"""
Reactively read the font(s) of an output.

Expand All @@ -1678,22 +1682,51 @@ def _read_input(self, key: str) -> str:
self._check_current_context(key)

id = ResolvedId(f".clientdata_{key}")
if id not in self._session.input:
if id not in self._session.root_scope().input:
raise ValueError(
f"ClientData value '{key}' not found. Please report this issue."
)

return self._session.input[id]()
return self._session.root_scope().input[id]()

def _read_output(self, id: str, key: str) -> str | None:
def _read_output(self, id: Id | None, key: str) -> str | None:
self._check_current_context(f"output_{key}")

# No `id` provided support
if id is None and self._current_output_name is not None:
id = self._current_output_name

if id is None:
raise ValueError(
"session.clientdata.output_*() requires an id when not called within "
"an output renderer."
)

# Module support
if not isinstance(id, ResolvedId):
id = self._session.ns(id)

input_id = ResolvedId(f".clientdata_output_{id}_{key}")
if input_id in self._session.input:
return self._session.input[input_id]()
if input_id in self._session.root_scope().input:
return self._session.root_scope().input[input_id]()
else:
return None

@contextlib.contextmanager
def _output_name_ctx(self, output_name: ResolvedId) -> Generator[None, None, None]:
"""
Context manager to temporarily set the output name.

This is used to allow `session.clientdata.output_*()` methods to access the
current output name without needing to pass it explicitly.
"""
old_output_name = self._current_output_name
try:
self._current_output_name = output_name
yield
finally:
self._current_output_name = old_output_name

@staticmethod
def _check_current_context(key: str) -> None:
try:
Expand Down Expand Up @@ -1798,8 +1831,12 @@ async def output_obs():
)

try:
value = await renderer.render()
with session.clientdata._output_name_ctx(output_name):
# Call the app's renderer function
value = await renderer.render()

session._outbound_message_queues.set_value(output_name, value)

except SilentOperationInProgressException:
session._send_progress(
"binding", {"id": output_name, "persistent": True}
Expand Down
33 changes: 33 additions & 0 deletions tests/playwright/shiny/session/current_output_info/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from shiny import App, Inputs, Outputs, Session, module, render, ui


@module.ui
def mod_ui():
return ui.output_text("info2").add_class("shiny-report-theme")


@module.server
def mod_server(input: Inputs, output: Outputs, session: Session):
@render.text
def info2():
bg_color = session.clientdata.output_bg_color()
return f"BG color: {bg_color}"


app_ui = ui.page_fluid(
ui.input_dark_mode(mode="light", id="dark_mode"),
ui.output_text("info").add_class("shiny-report-theme"),
mod_ui("mod1"),
)


def server(input: Inputs, output: Outputs, session: Session):
mod_server("mod1")

@render.text
def info():
bg_color = session.clientdata.output_bg_color()
return f"BG color: {bg_color}"


app = App(app_ui, server)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from playwright.sync_api import Page

from shiny.playwright import controller
from shiny.run import ShinyAppProc


def test_current_output_info(page: Page, local_app: ShinyAppProc) -> None:

page.goto(local_app.url)

# Check that we can get background color from clientdata
info = controller.OutputText(page, "info")
mod_info2 = controller.OutputText(page, "mod1-info2")
info.expect_value("BG color: rgb(255, 255, 255)")
mod_info2.expect_value("BG color: rgb(255, 255, 255)")

# Click the dark mode button to change the background color
dark_mode = controller.InputDarkMode(page, "dark_mode")
dark_mode.expect_mode("light")
dark_mode.click()
dark_mode.expect_mode("dark")

# Check that the background color has changed
info.expect_value("BG color: rgb(29, 31, 33)")
mod_info2.expect_value("BG color: rgb(29, 31, 33)")
Loading