Skip to content
Draft
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
23 changes: 23 additions & 0 deletions .github/workflows/pytest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,28 @@ jobs:
docker inspect --format '{{json .State}}' playwright || true
docker logs playwright || true

# End-to-end tests for behavior that differs on Windows. The other Playwright
# jobs run on Ubuntu, where these tests pass either way. For example, on
# Windows a websocket write made while an earlier write is still in flight goes
# out only when the event loop runs again (#1381).
playwright-windows:
if: github.event_name != 'release'
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup py-shiny
uses: ./.github/py-shiny/setup

- name: Run Windows-specific End-to-End tests
timeout-minutes: 10
shell: bash
run: |
# No `--with-deps`: on Windows it only adds the Media Foundation feature,
# which headless Chromium doesn't need, and that takes about 3 minutes.
make playwright-shiny SUB_FILE="bugs/1381-busy-indicator-progress" PLAYWRIGHT_BROWSERS=chromium PLAYWRIGHT_INSTALL_ARGS=--only-shell PYTEST_BROWSERS="--browser chromium"

playwright-examples:
if: github.event_name != 'release'
runs-on: ubuntu-latest
Expand Down Expand Up @@ -467,6 +489,7 @@ jobs:
- pyright
- oldest-deps
- playwright-shiny
- playwright-windows
- playwright-examples
- playwright-ai
- playwright-deploys-precheck
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Bug fixes

* On Windows, an output's busy indicator now shows while the output renders. Before, when a synchronous render function blocked the event loop, the message that turns on the indicator reached the browser together with the output's new value, so most outputs never showed it. (#1381)

* Setting a reactive value from a download handler (`@render.download_button`) now updates the outputs and effects that read it right away, including while a streamed download is still sending. The session also keeps handling input during a streamed download. Before, they updated only after the next message from the client. (#1785)

* `near_points(add_dist=True)` now adds the `dist_` column its documentation describes, instead of a column named `dist`. Shiny for R names it `dist_` as well, and the trailing underscore is what keeps it from colliding with a `dist` column of the caller's own data. Code reading `df["dist"]` from the result must read `df["dist_"]`. (#2510)
Expand Down
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ clean-js: FORCE
SUB_FILE:=
PYTEST_BROWSERS:= --browser webkit --browser firefox --browser chromium
PYTEST_DEPLOYS_BROWSERS:= --browser chromium
# Arguments for `playwright install`, besides the browsers.
PLAYWRIGHT_INSTALL_ARGS:= --with-deps
# Per-test timeout (seconds) so a single hung test fails fast with a full
# thread-stack dump instead of silently consuming the whole CI job.
PLAYWRIGHT_TEST_TIMEOUT:= 120
Expand Down Expand Up @@ -236,7 +238,7 @@ install-playwright: FORCE
@if [ -n "$$PW_TEST_CONNECT_WS_ENDPOINT" ]; then \
echo "Using remote Playwright server at $$PW_TEST_CONNECT_WS_ENDPOINT"; \
else \
playwright install --with-deps $(PLAYWRIGHT_BROWSERS); \
playwright install $(PLAYWRIGHT_INSTALL_ARGS) $(PLAYWRIGHT_BROWSERS); \
fi

install-rsconnect: FORCE
Expand Down
6 changes: 6 additions & 0 deletions shiny/session/_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -2952,6 +2952,12 @@ async def output_obs():
await session._send_message(
{"recalculating": {"name": output_name, "status": "recalculating"}}
)
# Let the message go out before a synchronous renderer blocks the event
# loop, so that the output's busy indicator shows. On Windows, a write
# made while an earlier write is still in flight goes out only when the
# loop runs again.
# https://github.com/posit-dev/py-shiny/issues/1381
await asyncio.sleep(0)

try:
async with shiny_otel_span(
Expand Down
38 changes: 38 additions & 0 deletions tests/playwright/shiny/bugs/1381-busy-indicator-progress/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import time

from shiny import App, Inputs, render, ui

app_ui = ui.page_fluid(
ui.input_action_button("rerender", "Re-render"),
ui.output_text("out1"),
ui.output_text("out2"),
ui.output_text("out3"),
ui.output_text("out4"),
)


def server(input: Inputs):
def slow_value() -> str:
n = input.rerender()
# Block the event loop, as the plots in #1381 did.
time.sleep(0.5)
return str(n)

@render.text
def out1():
return slow_value()

@render.text
def out2():
return slow_value()

@render.text
def out3():
return slow_value()

@render.text
def out4():
return slow_value()


app = App(app_ui, server)
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
Each output's "recalculating" message must reach the browser while the output
renders, so that its busy indicator shows. In #1381, on Windows, the messages for
most outputs arrived together with the finished values.
"""

import json
from typing import cast

from playwright.sync_api import Page

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

OUTPUTS = ["out1", "out2", "out3", "out4"]
# Each output blocks the event loop for 0.5 s while it renders (see app.py).
MIN_GAP_MS = 250

# Records when the browser receives each websocket message.
RECORD_MESSAGES = """
window.__shinyMessages = [];
window.WebSocket = class extends window.WebSocket {
constructor(...args) {
super(...args);
this.addEventListener("message", (e) => {
window.__shinyMessages.push([performance.now(), e.data]);
});
}
};
"""


def status_times(page: Page) -> dict[tuple[str, str], float]:
"""The first time that each (output, status) message was received, in ms."""
times: dict[tuple[str, str], float] = {}
messages: list[tuple[float, object]] = page.evaluate("window.__shinyMessages")
for t, data in messages:
if not isinstance(data, str):
continue
try:
msg: object = json.loads(data)
except ValueError:
continue
if not isinstance(msg, dict):
continue
recalc = cast("dict[str, object]", msg).get("recalculating")
if isinstance(recalc, dict):
status = cast("dict[str, str]", recalc)
times.setdefault((status["name"], status["status"]), t)
return times


def test_recalculating_messages_arrive_while_outputs_render(
page: Page, local_app: ShinyAppProc
) -> None:
page.add_init_script(RECORD_MESSAGES)
page.goto(local_app.url)
controller.OutputText(page, "out4").expect_value("0", timeout=10_000)

page.evaluate("window.__shinyMessages = []")
controller.InputActionButton(page, "rerender").click()
controller.OutputText(page, "out4").expect_value("1", timeout=10_000)

times = status_times(page)
gaps = {
name: times[(name, "recalculated")] - times[(name, "recalculating")]
for name in OUTPUTS
}
print("ms between recalculating and recalculated:", gaps)
assert all(gap >= MIN_GAP_MS for gap in gaps.values()), gaps
8 changes: 4 additions & 4 deletions tests/playwright/shiny/otel/express-session-start/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
re-executed for every new session, but it only reads from the exporter —
it never replaces the TracerProvider.

On initial page load the span_summary output shows 0 session_start spans
because session_start hasn't ended yet (the initial flush runs inside it).
After clicking "Show Session Spans" the re-render fires outside session_start,
so the now-closed span is visible in the exporter.
The initial flush runs inside session_start, but an output yields before it
renders, so on initial page load span_summary can show 0 or 1 session_start
spans. After clicking "Show Session Spans" the re-render fires outside
session_start, so the now-closed span is visible in the exporter.
"""

import json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ def test_session_start_span_closes(page: Page, local_app: ShinyAppProc) -> None:
show_spans_btn = InputActionButton(page, "show_spans")
output = OutputCode(page, "span_summary")

# Before clicking, the initial render fires inside session_start's
# reactive_flush (before the span ends), so the count must be 0.
expect(output.loc).to_contain_text('"session_start_count": 0,')
# The initial render can run before or after session_start ends (an output
# yields before it renders), so only wait for it here.
expect(output.loc).to_contain_text('"session_start_count":')

# Click after page load so the re-render fires *outside* session_start.
show_spans_btn.click()
Expand Down
Loading