fix(ui): stop ArtifactViewer iframe runaway with vh/% artifacts - #205
fix(ui): stop ArtifactViewer iframe runaway with vh/% artifacts#205diegoscarabelli wants to merge 3 commits into
Conversation
The `ResizeObserver` that synced iframe height to `scrollHeight` created a
positive-feedback loop for any artifact using viewport-relative units on a
plot container: growing the iframe grew `vh` inside → grew the plot →
grew `scrollHeight` → fired the observer again. Chromium's silent
`ResizeObserver loop limit exceeded` guard eventually froze the iframe at
some absurd height, leaving Plotly colorbars stretched to fill the viewport
and choropleths squished off-screen. No console output, no diagnostic
trail — hours of bisecting the artifact before the host code came into
suspicion.
Change the iframe height policy:
1. Default: fill the parent panel (`height: 100%`), so `vh` inside
resolves to the panel's stable height rather than the iframe's own
runtime height.
2. Grow past the panel only when content genuinely exceeds it (long
reports, database viewer after query results arrive). The parent Box
still handles the scroll, preserving the dark-mode invert-filter
workaround from 1e9a4e4.
3. Loop guard: if scrollHeight grows again within one animation frame of
our last size-set, treat it as vh feedback and ignore. Legitimate
growth (query results, images loading, user interaction) happens
between frames and passes.
4. One-shot `console.warn` when the guard trips so the failure stops
being silent.
Reproduced against `wid_distribution_map.html` in a wrapper that mimics
ArtifactViewer's setup: broken state → iframe 1315 px, choropleth
593 × 308, colorbar 75 × 809. After the fix → iframe stable, choropleth
727 × 378 (correct 2:1 aspect), colorbar 75 × 462.
8dadca4 to
11b0a84
Compare
There was a problem hiding this comment.
Pull request overview
This PR fixes an iframe auto-resize feedback loop in ArtifactViewer that could cause runaway height growth (and silent Chromium ResizeObserver loop limiting) when embedded HTML artifacts use viewport-relative sizing (vh/%), improving stability for Plotly dashboards and other responsive artifacts.
Changes:
- Changes the iframe sizing policy to default to filling the parent panel (
height: 100%) and only grow beyond the panel when content genuinely exceeds it. - Adds a feedback-loop guard (100ms window) and a one-shot
console.warnwhen avh/%-style loop is detected. - Documents the fix in
CHANGELOG.md.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/ui/components/ArtifactViewer.tsx | Updates iframe resize behavior to prevent vh/% positive-feedback loops while preserving parent-scrolling behavior. |
| CHANGELOG.md | Adds an Unreleased “Fixed” entry describing the iframe runaway fix and guard behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (scrollHeight !== lastSetHeight) { | ||
| iframe.style.height = `${scrollHeight}px`; | ||
| lastSetHeight = scrollHeight; | ||
| lastSetAt = performance.now(); | ||
| } |
There was a problem hiding this comment.
Both fixed in bec7c32.
setup()now callsobserver?.disconnect()before creating a new observer, so re-invocations (immediate +load, or if the artifact reloads itself) can't stack observers.- Observer now watches both
doc.bodyandparent, so a panel resize with unchanged body content still re-runsresizeToContentand reverts toheight: 100%when the panel now fits the content.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/ui/components/ArtifactViewer.tsx:260
- This inline comment says the loop guard is “within one animation frame”, but the implementation uses LOOP_WINDOW_MS = 100ms. Updating the wording avoids confusion when someone adjusts or reasons about the threshold.
// Loop guard: growth within one animation frame of our own set is
// almost certainly `vh` feedback, not real content growth.
src/ui/components/ArtifactViewer.tsx:215
- The comment describes the loop guard as triggering within “one animation frame”, but the actual window is 100ms (LOOP_WINDOW_MS). This is misleading documentation; please align the comment wording with the implemented threshold.
This issue also appears on line 259 of the same file.
// 3. Loop guard: if scrollHeight grows again within one animation frame
// of our last size-set, treat it as `vh`-feedback and ignore. Without
// this, an artifact whose `vh`-based container grows as the iframe
CHANGELOG.md:12
- The changelog calls this a “one-frame throttle”, but the documented behavior (and implementation) is a 100ms window. Consider rephrasing to avoid implying a ~16ms frame-based throttle.
- `ArtifactViewer`'s iframe no longer runs away when an artifact uses viewport-relative units (`vh`, `%`) on a plot container. Previously the `ResizeObserver` that synced iframe height to content would grow the iframe → grow `vh` inside → grow scrollHeight → fire the observer again, ending in Chromium's silent `ResizeObserver loop limit exceeded` guard freezing the iframe at some absurd height (Plotly colorbars stretched to fill the viewport, choropleths squished off-screen). The iframe now defaults to filling its panel (`height: 100%`) so `vh` resolves to the panel's stable height, and the observer only grows past the panel when scrollHeight genuinely exceeds it (long reports, database viewer after query results arrive) — the parent panel scrolls, preserving the routing that keeps the CSS invert filter off the scrollbar. A one-frame throttle catches remaining feedback (scrollHeight growth within 100 ms of a size-set is ignored) and a one-shot `console.warn` fires when the guard trips so the failure stops being silent. See #204.
Addresses Copilot review feedback on #205: - `setup()` runs both immediately in the effect and via the iframe `load` event, and load can fire again if the artifact reloads itself. Without disconnecting first, each re-invocation leaked another ResizeObserver on the body. Disconnect any prior observer before creating a new one. - `resizeToContent` reads `parent.clientHeight` to decide whether to fill-parent or grow-past-parent, but only observed `doc.body`. When the parent panel resizes (window resize, layout change) without the body changing, the iframe stayed at its stale explicit pixel height. Observe the parent element too so a panel-size change re-runs the decision and reverts to `height: 100%` when the panel now fits the content.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/ui/components/ArtifactViewer.tsx:270
- The console warning says the guard is "freezing" the iframe height, but the code is actually skipping/throttling a resize (a later resize may still occur after LOOP_WINDOW_MS). Adjusting the wording will make the diagnostic more accurate.
console.warn(
`[ArtifactViewer] Iframe content grew from ${lastSetHeight}px to ${scrollHeight}px within ${LOOP_WINDOW_MS}ms of the last resize. Suspected vh/% feedback loop; freezing iframe height. Prefer fixed pixel heights for plot containers. See #204.`
);
src/ui/components/ArtifactViewer.tsx:215
- The comment says the loop guard triggers "within one animation frame", but the implementation uses a 100ms window (LOOP_WINDOW_MS = 100), which is much longer than a frame and can be misleading for future maintainers. Consider describing the guard in terms of the actual time window.
This issue also appears on line 268 of the same file.
// 3. Loop guard: if scrollHeight grows again within one animation frame
// of our last size-set, treat it as `vh`-feedback and ignore. Without
// this, an artifact whose `vh`-based container grows as the iframe
CHANGELOG.md:12
- This changelog entry calls the guard a "one-frame throttle" but then describes a 100ms window. Since 100ms is several frames, consider rewording to avoid implying it’s tied to a single animation frame.
- `ArtifactViewer`'s iframe no longer runs away when an artifact uses viewport-relative units (`vh`, `%`) on a plot container. Previously the `ResizeObserver` that synced iframe height to content would grow the iframe → grow `vh` inside → grow scrollHeight → fire the observer again, ending in Chromium's silent `ResizeObserver loop limit exceeded` guard freezing the iframe at some absurd height (Plotly colorbars stretched to fill the viewport, choropleths squished off-screen). The iframe now defaults to filling its panel (`height: 100%`) so `vh` resolves to the panel's stable height, and the observer only grows past the panel when scrollHeight genuinely exceeds it (long reports, database viewer after query results arrive) — the parent panel scrolls, preserving the routing that keeps the CSS invert filter off the scrollbar. A one-frame throttle catches remaining feedback (scrollHeight growth within 100 ms of a size-set is ignored) and a one-shot `console.warn` fires when the guard trips so the failure stops being silent. See #204.
Copilot round 2 flagged wording drift between the code and its description: - Comment said "within one animation frame" but LOOP_WINDOW_MS is 100 ms (several frames at 60fps). Restate the guard in terms of the actual window and note the deliberate margin over the ~one-paint feedback round trip we're guarding against. - `console.warn` said "freezing iframe height", but the code only skips this single fire — a later fire outside the window is still applied. Say "skipping this resize" instead. - Same rewording in the CHANGELOG entry. No behavior change.
Fixes #204.
Problem
ArtifactViewerset the iframe's height todoc.documentElement.scrollHeighton everyResizeObserverfire. Any artifact whose plot container usedvh/%entered a positive-feedback loop:vhinside grows →#some-container { height: NNvh }grows.scrollHeightgrows → observer fires → iframe grown again.ResizeObserver loop limit exceededguard froze the iframe at some absurd height.Symptom in Plotly-based dashboards: colorbar (
len: 1= full plot-area height) became a giant vertical bar, choropleth (fixed ~2:1 aspect) got squished to a small strip off-screen. Discovered when conductor 5 added a second plot towid_distribution_map.html— hours of bisecting inside the artifact before the host turned out to be the culprit. The failure is silent (no console output) so a future author reaching forvhwould hit the same trap with no diagnostic trail.Change
Three-part iframe height policy:
iframe.style.height = '100%'on mount).vhinside the iframe now resolves to the panel's stable height rather than the iframe's own runtime height, so an artifact usingheight: 68vhsizes to the panel as the author expected.Boxstill hasoverflow: autoand handles the scroll, preserving the dark-mode invert-filter workaround from 1e9a4e4 (scrollbar stays outside the CSSfilter: invert()in dark mode).scrollHeightgrows again within one animation frame (100 ms) of our own size-set, treat it asvhfeedback and skip. A one-shotconsole.warnfires when the guard trips so the failure stops being silent.Legitimate content growth (query results, images loading, user interaction) happens between frames and passes the throttle unchanged.
Verification
Reproduced with a wrapper that mimics
ArtifactViewer's setup aroundwid_distribution_map.html(pre-workaround,height: 68vh):g.geolayer)console.warnwhen the feedback loop triggersScreenshots of the broken and fixed reproductions are in the debug session notes.
pnpm check,pnpm typecheck,pnpm build,pnpm test(1150 tests, 59 files) all pass. The pre-existing BiomenoUnusedFunctionParameterswarning ongetLineCountFromHeaderinsrc/server/agents/tools/bash.test.tspredates this branch and is unrelated.Notes
wid_distribution_map.htmlartifact that triggered this has already been worked around author-side (fixed pixel heights per plot, per the multi-plot scrolling-document pattern). This PR ensures the next artifact author who reaches forvhgets a normal-looking result instead of a silent freeze.ResizeObserver+ measurable layout, and there's no existing jsdom scaffolding for that. Wrapper-level reproduction was used instead.