Skip to content

Commit eb08683

Browse files
authored
Update streamlit to avoid scroll bug versions (#130)
Signed-off-by: Luca Foppiano <luca@foppiano.org>
1 parent 5926d9c commit eb08683

5 files changed

Lines changed: 193 additions & 13 deletions

File tree

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,14 @@ Here are some caveats to be aware of:
5353

5454
## Streamlit compatibility
5555

56-
Streamlit 1.41 has a regression in custom-component iframe handling that can reset the page
57-
scroll position after an `st.dialog` is closed — see issue
56+
Streamlit **1.41–1.58** have a regression in custom-component iframe handling that resets the
57+
page scroll position after an `st.dialog` is closed — see issue
5858
[#107](https://github.com/lfoppiano/streamlit-pdf-viewer/issues/107) for details and the bisect
59-
data. The last fully working Streamlit version in our testing is 1.40.2.
59+
data. This was **fixed upstream in Streamlit 1.59.0**.
6060

61-
If this regression affects your app, pin `streamlit<1.41` in your requirements. A runtime
62-
`UserWarning` is emitted at import time whenever a newer Streamlit is detected.
61+
Recommended: use **Streamlit ≥ 1.59.0** (verified against the full test suite). Streamlit ≤ 1.40.2
62+
also works. Avoid the 1.41–1.58 range; a runtime `UserWarning` is emitted at import time when an
63+
affected version is detected.
6364

6465
## Getting started
6566

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
streamlit==1.40.1
1+
streamlit==1.59.1
22
bump-my-version==1.3.0
33
tornado>=6.5 # not directly required, pinned by Snyk to avoid a vulnerability
44
requests>=2.32.4 # not directly required, pinned by Snyk to avoid a vulnerability

streamlit_pdf_viewer/__init__.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,22 @@
66
import streamlit.components.v1 as components
77
import json
88

9-
# Streamlit >= 1.41 introduced a regression in custom-component iframe handling
10-
# that resets the page scroll position after an st.dialog is closed (see
11-
# https://github.com/lfoppiano/streamlit-pdf-viewer/issues/107). Surface this to
12-
# users at import time so it's visible without reading release notes.
9+
# Streamlit 1.41-1.58 have a regression in custom-component iframe handling that
10+
# resets the page scroll position after an st.dialog is closed (see
11+
# https://github.com/lfoppiano/streamlit-pdf-viewer/issues/107). It was fixed
12+
# upstream in Streamlit 1.59.0. Surface the affected range to users at import time
13+
# so it's visible without reading release notes.
1314
try:
1415
import warnings as _warnings
1516
import streamlit as _st
1617
from packaging.version import Version as _V
1718

18-
if _V(_st.__version__) >= _V("1.41.0"):
19+
if _V("1.41.0") <= _V(_st.__version__) < _V("1.59.0"):
1920
_warnings.warn(
20-
"streamlit-pdf-viewer: Streamlit >= 1.41 has a known regression "
21+
"streamlit-pdf-viewer: Streamlit 1.41-1.58 have a known regression "
2122
"that may reset the page scroll after an st.dialog is closed. "
2223
"See https://github.com/lfoppiano/streamlit-pdf-viewer/issues/107 . "
23-
"Pin streamlit<1.41 if this affects your app.",
24+
"Upgrade to streamlit>=1.59 (fixed) or pin streamlit<=1.40.2.",
2425
UserWarning,
2526
stacklevel=2,
2627
)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright 2025 Streamlit PDF Component
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Reproduction app for streamlit/streamlit#14917.
17+
18+
Renders the PDF viewer at the top of the page with clickable annotations. Clicking
19+
a page-2 annotation scrolls the page down (the PDF is tall) and opens an
20+
``st.dialog``; closing that dialog triggers a rerun. On affected Streamlit versions
21+
(1.41-1.58) the parent page scroll (``section[data-testid="stMain"]``) resets to the
22+
top when the dialog closes; on <= 1.40.2 and >= 1.59.0 it is preserved.
23+
"""
24+
25+
import os
26+
27+
import streamlit as st
28+
from tests import ROOT_DIRECTORY
29+
30+
from streamlit_pdf_viewer import pdf_viewer
31+
32+
st.subheader("Dialog scroll-reset reproduction (issue #14917)")
33+
34+
35+
@st.dialog("Annotation")
36+
def show_popup(annotation):
37+
st.write("Close me (Escape / X) and observe whether the page scroll is preserved.")
38+
st.json(annotation)
39+
40+
41+
# The component's returned value is sticky: on_annotation_click fires again on the
42+
# rerun caused by closing the dialog. Gate on session_state so the dialog opens once
43+
# per click and stays closed afterwards (otherwise it would immediately reopen).
44+
def handle_click(annotation):
45+
if not st.session_state.get("dialog_shown"):
46+
st.session_state["dialog_shown"] = True
47+
show_popup(annotation)
48+
49+
50+
annotations = [
51+
{"page": 1, "x": 100, "y": 100, "height": 22, "width": 30, "color": "blue"},
52+
{"page": 1, "x": 150, "y": 120, "height": 22, "width": 30, "color": "green"},
53+
{"page": 2, "x": 180, "y": 130, "height": 22, "width": 30, "color": "purple"},
54+
{"page": 2, "x": 220, "y": 155, "height": 22, "width": 30, "color": "red"},
55+
]
56+
57+
pdf_viewer(
58+
os.path.join(ROOT_DIRECTORY, "resources/test.pdf"),
59+
width=800,
60+
annotations=annotations,
61+
on_annotation_click=handle_click,
62+
)

tests/test_dialog_scroll_reset.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Copyright 2025 Streamlit PDF Component
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""E2E regression test for streamlit/streamlit#14917.
17+
18+
When an ``st.dialog`` opened from a custom component is closed, the parent page
19+
scroll (``section[data-testid="stMain"]``) must be preserved.
20+
21+
Measured behaviour of this exact scenario (click a page-2 annotation, which scrolls
22+
the page down, then close the dialog) across Streamlit versions:
23+
- <= 1.40.2 : preserved (pre-regression)
24+
- 1.41.0 - 1.58.0 : parent scroll resets to 0 on dialog close (the #14917 bug)
25+
- >= 1.59.0 : preserved (fixed upstream)
26+
27+
The test asserts the desired invariant (scroll preserved), so it PASSES on fixed
28+
Streamlit (>= 1.59) and FAILS on the affected range, acting as a compatibility guard.
29+
Skipped where ``st.dialog`` is unavailable (Streamlit < 1.37).
30+
"""
31+
32+
import os
33+
from pathlib import Path
34+
35+
import pytest
36+
import streamlit as st
37+
from playwright.sync_api import Page, expect
38+
39+
from tests import ROOT_DIRECTORY
40+
from tests.e2e_utils import StreamlitRunner
41+
42+
APP_FILE = os.path.join(ROOT_DIRECTORY, "tests", "streamlit_apps", "example_dialog_scroll.py")
43+
44+
pytestmark = pytest.mark.skipif(
45+
not hasattr(st, "dialog"),
46+
reason="st.dialog (required to reproduce #14917) is unavailable on this Streamlit",
47+
)
48+
49+
STMAIN = 'section[data-testid="stMain"]'
50+
IFRAME = 'iframe[title="streamlit_pdf_viewer.streamlit_pdf_viewer"]'
51+
DIALOG = '[data-testid="stDialog"]'
52+
53+
# "reset to 0" vs "preserved" is a wide gap; allow small layout jitter.
54+
TOLERANCE_PX = 50
55+
56+
57+
@pytest.fixture(scope="session")
58+
def browser_type_launch_args(browser_type_launch_args):
59+
return {
60+
**browser_type_launch_args,
61+
"firefox_user_prefs": {"pdfjs.disabled": False},
62+
}
63+
64+
65+
@pytest.fixture(autouse=True, scope="module")
66+
def streamlit_app():
67+
with StreamlitRunner(Path(APP_FILE)) as runner:
68+
yield runner
69+
70+
71+
@pytest.fixture(autouse=True, scope="function")
72+
def go_to_app(page: Page, streamlit_app: StreamlitRunner):
73+
page.goto(streamlit_app.server_url)
74+
expect(page.get_by_role("img", name="Running...")).not_to_be_visible()
75+
76+
77+
def _scroll_top(page: Page) -> float:
78+
return page.evaluate(f"document.querySelector('{STMAIN}').scrollTop")
79+
80+
81+
def test_parent_scroll_preserved_after_dialog_close(page: Page):
82+
# Wait for the component to render. The component iframe starts hidden until
83+
# ready, so wait for it to be attached and for the PDF canvas + a page-2
84+
# annotation to render rather than asserting outer-iframe visibility.
85+
page.locator(IFRAME).nth(0).wait_for(state="attached", timeout=30000)
86+
frame = page.frame_locator(IFRAME).nth(0)
87+
expect(frame.locator('div[id="pdfViewer"] canvas').nth(0)).to_be_visible(timeout=30000)
88+
# data-index "2" is the first page-2 annotation; clicking it scrolls the page
89+
# down (giving a non-zero reference) and opens the dialog.
90+
annotation = frame.locator('div[data-index="2"]')
91+
expect(annotation).to_be_visible(timeout=15000)
92+
93+
annotation.click()
94+
dialog = page.locator(DIALOG)
95+
expect(dialog).to_be_visible(timeout=15000)
96+
page.wait_for_timeout(400)
97+
98+
# Scroll position at the moment the dialog is open is the reference the page
99+
# must return to after the dialog closes.
100+
scroll_when_open = _scroll_top(page)
101+
assert scroll_when_open > TOLERANCE_PX, (
102+
f"Precondition failed: page did not scroll on annotation click "
103+
f"({scroll_when_open}px); cannot detect a reset."
104+
)
105+
106+
# Close the dialog -> triggers the rerun that resets the scroll on affected builds.
107+
page.keyboard.press("Escape")
108+
expect(dialog).not_to_be_visible(timeout=15000)
109+
page.wait_for_timeout(700)
110+
111+
after = _scroll_top(page)
112+
delta = abs(after - scroll_when_open)
113+
assert delta <= TOLERANCE_PX, (
114+
f"Parent scroll not preserved after dialog close (#14917): "
115+
f"open={scroll_when_open}px, after={after}px, delta={delta}px"
116+
)

0 commit comments

Comments
 (0)