Skip to content

Commit 6f91b60

Browse files
author
Robert van 't Hof
committed
Admin UI: migrate off deprecated st.components.v1.html to st.iframe
streamlit.components.v1.html is deprecated in the pinned streamlit==1.58.0 in favor of the unified st.iframe API. Both the tooltip-tab-order fix (branding.py) and the log auto-scroll fix (3_Logs.py) relied on it, so migrate both call sites and fix the regressions the migration surfaced live: a create-once MutationObserver that never got a second chance to re-attach after a lost mount, a rotted data-testid selector on the log container predating the migration, and a first-render layout timing race.
1 parent 71fbd5e commit 6f91b60

3 files changed

Lines changed: 45 additions & 23 deletions

File tree

Admin UI/app/branding.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from pathlib import Path
1616

1717
import streamlit as st
18-
import streamlit.components.v1 as components
1918

2019
_LOGO_PATH = str((Path(__file__).parent / "assets" / "siemens-logo-white.svg").resolve())
2120

@@ -61,6 +60,7 @@
6160
# newly-rendered icons across reruns and page switches, so it only needs to
6261
# be injected once.
6362
_SKIP_TOOLTIP_TAB_STOPS_JS = """
63+
<style>body{margin:0;overflow:hidden}</style>
6464
<script>
6565
const doc = window.parent.document;
6666
// Natively-focusable tags need no explicit tabindex attribute (a plain
@@ -76,15 +76,21 @@
7676
});
7777
};
7878
detab();
79-
if (!doc.__mendixTooltipObserver) {
80-
doc.__mendixTooltipObserver = new MutationObserver(detab);
81-
// attributes:true too - React re-applies its own tabIndex prop on
82-
// rerender without necessarily removing/reinserting the node, which a
83-
// childList-only observer would miss.
84-
doc.__mendixTooltipObserver.observe(doc.body, {
85-
childList: true, subtree: true, attributes: true, attributeFilter: ['tabindex'],
86-
});
87-
}
79+
// Disconnect + recreate on every mount rather than "create once, ever":
80+
// this script forces a fresh mount on every rerun (see the Python-side
81+
// comment on the rerun counter), so a create-once observer that turns out
82+
// to be watching a target that's gone stale - e.g. it was attached on a
83+
// different page before the user navigated here - would otherwise never
84+
// get a second chance to re-attach for the rest of the browser session.
85+
// Recreating it every time this script runs makes that self-healing.
86+
if (doc.__mendixTooltipObserver) doc.__mendixTooltipObserver.disconnect();
87+
doc.__mendixTooltipObserver = new MutationObserver(detab);
88+
// attributes:true too - React re-applies its own tabIndex prop on
89+
// rerender without necessarily removing/reinserting the node, which a
90+
// childList-only observer would miss.
91+
doc.__mendixTooltipObserver.observe(doc.body, {
92+
childList: true, subtree: true, attributes: true, attributeFilter: ['tabindex'],
93+
});
8894
</script>
8995
"""
9096

@@ -94,16 +100,16 @@ def apply_branding() -> None:
94100
after st.set_page_config. Idempotent within a rerun."""
95101
st.logo(_LOGO_PATH, size="large")
96102
st.markdown(_CSS, unsafe_allow_html=True)
97-
# components.html reuses the same iframe DOM node across reruns when its
98-
# content is unchanged, so an unqualified call only mounts (and only runs
99-
# the script inside) once per browser session. If that one mount is lost -
103+
# st.iframe reuses the same iframe DOM node across reruns when its content
104+
# is unchanged, so an unqualified call only mounts (and only runs the
105+
# script inside) once per browser session. If that one mount is lost -
100106
# a dropped WebSocket during a cold container start or a redeploy landing
101107
# mid-session - the tooltip-tab-order fix below never applies for the rest
102108
# of the session, with nothing visibly wrong to notice. A per-rerun counter
103109
# in the HTML forces a fresh mount (and therefore a fresh attempt) on every
104110
# single rerun, so a lost mount just gets retried on the next interaction.
105111
st.session_state["_tooltip_fix_rerun"] = st.session_state.get("_tooltip_fix_rerun", 0) + 1
106-
components.html(
112+
st.iframe(
107113
_SKIP_TOOLTIP_TAB_STOPS_JS + f"<!-- rerun {st.session_state['_tooltip_fix_rerun']} -->",
108-
height=0,
114+
height=1,
109115
)

Admin UI/app/pages/3_Logs.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
sys.path.append(str(Path(__file__).resolve().parent.parent))
88

99
import streamlit as st
10-
import streamlit.components.v1 as components
1110

1211
from auth import client, is_privileged_operator
1312
from controller_client import ControllerError
@@ -102,7 +101,7 @@ def _scroll_script(is_first_render: bool, logs: str) -> str:
102101
user was already pinned to the bottom — chat-client tail behavior.
103102
"""
104103
first_js = "true" if is_first_render else "false"
105-
# components.html renders through a React.memo'd <iframe srcDoc=...>: if this
104+
# st.iframe renders through a React.memo'd <iframe srcDoc=...>: if this
106105
# function returns byte-identical HTML on every tick (it does, once
107106
# is_first_render settles to False), React never touches the DOM and the
108107
# <script> below never runs again, so auto-refresh ticks stop being noticed.
@@ -111,10 +110,16 @@ def _scroll_script(is_first_render: bool, logs: str) -> str:
111110
nonce = hash(logs) & 0xFFFFFFFF
112111
return f"""
113112
<!-- logs:{nonce} -->
113+
<style>body{{margin:0;overflow:hidden}}</style>
114114
<script>
115115
const doc = window.parent.document;
116116
const snap = () => {{
117-
const wraps = doc.querySelectorAll('[data-testid="stVerticalBlockBorderWrapper"]');
117+
// "stVerticalBlockBorderWrapper" was this app's original testid for a
118+
// bordered container; a later Streamlit version renamed it to the more
119+
// generic "stVerticalBlock" (used for every vertical block, not just
120+
// bordered ones - the loop below still needs to pick out the one that's
121+
// actually overflowing, since most matches on a page won't be).
122+
const wraps = doc.querySelectorAll('[data-testid="stVerticalBlock"]');
118123
if (!wraps.length) return;
119124
// The fixed-height container scrolls on the wrapper or an inner element;
120125
// find the last actually-scrollable node so scrollTop lands.
@@ -131,6 +136,17 @@ def _scroll_script(is_first_render: bool, logs: str) -> str:
131136
}};
132137
// Defer past layout + syntax highlighting so scrollHeight is final.
133138
requestAnimationFrame(() => requestAnimationFrame(snap));
139+
// The first render competes with the rest of the page's own initial mount
140+
// (branding script, sidebar, other widgets) for layout time, so a large log
141+
// block's syntax highlighting may not have finished settling after just two
142+
// animation frames - unlike a later, isolated auto-refresh tick, which has
143+
// nothing else competing with it. Re-snap over a short window so a
144+
// late-settling layout still gets caught instead of leaving the view stuck
145+
// partway (snap() re-measures scrollHeight fresh each call, so repeating it
146+
// is harmless once things have already settled).
147+
if ({first_js}) {{
148+
[100, 300, 600].forEach((ms) => setTimeout(snap, ms));
149+
}}
134150
</script>
135151
"""
136152

@@ -178,7 +194,7 @@ def _log_view() -> None:
178194

179195
is_first_render = _SCROLL_INIT_KEY not in st.session_state
180196
st.session_state[_SCROLL_INIT_KEY] = True
181-
components.html(_scroll_script(is_first_render, logs), height=0)
197+
st.iframe(_scroll_script(is_first_render, logs), height=1)
182198

183199

184200
_log_view()

Admin UI/tests/test_branding.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
class TestApplyBranding:
99
def test_tooltip_script_html_differs_every_call(self, monkeypatch):
10-
"""components.html reuses its iframe DOM node across reruns when the
11-
HTML argument is unchanged, which silently stops the tooltip-tab-order
10+
"""st.iframe reuses its iframe DOM node across reruns when the HTML
11+
argument is unchanged, which silently stops the tooltip-tab-order
1212
script from ever running again if a single mount is lost (dropped
1313
WebSocket during a cold start, a redeploy mid-session). Each call must
1414
pass different HTML so the frontend always treats it as a fresh
@@ -17,7 +17,7 @@ def test_tooltip_script_html_differs_every_call(self, monkeypatch):
1717
monkeypatch.setattr(branding.st, "logo", lambda *a, **k: None)
1818
monkeypatch.setattr(branding.st, "markdown", lambda *a, **k: None)
1919
seen = []
20-
monkeypatch.setattr(branding.components, "html", lambda html, **k: seen.append(html))
20+
monkeypatch.setattr(branding.st, "iframe", lambda html, **k: seen.append(html))
2121

2222
branding.apply_branding()
2323
branding.apply_branding()
@@ -36,7 +36,7 @@ def test_rerun_counter_persists_across_script_reruns(self, monkeypatch):
3636
st.session_state.clear()
3737
monkeypatch.setattr(branding.st, "logo", lambda *a, **k: None)
3838
monkeypatch.setattr(branding.st, "markdown", lambda *a, **k: None)
39-
monkeypatch.setattr(branding.components, "html", lambda html, **k: None)
39+
monkeypatch.setattr(branding.st, "iframe", lambda html, **k: None)
4040

4141
branding.apply_branding()
4242
first = st.session_state["_tooltip_fix_rerun"]

0 commit comments

Comments
 (0)