Skip to content

fix(ui): stop ArtifactViewer iframe runaway with vh/% artifacts - #205

Open
diegoscarabelli wants to merge 3 commits into
mainfrom
fix/artifact-viewer-vh-loop-guard
Open

fix(ui): stop ArtifactViewer iframe runaway with vh/% artifacts#205
diegoscarabelli wants to merge 3 commits into
mainfrom
fix/artifact-viewer-vh-loop-guard

Conversation

@diegoscarabelli

Copy link
Copy Markdown
Owner

Fixes #204.

Problem

ArtifactViewer set the iframe's height to doc.documentElement.scrollHeight on every ResizeObserver fire. Any artifact whose plot container used vh/% entered a positive-feedback loop:

  1. Iframe height grows → vh inside grows → #some-container { height: NNvh } grows.
  2. Body scrollHeight grows → observer fires → iframe grown again.
  3. Loop until Chromium's silent ResizeObserver loop limit exceeded guard 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 to wid_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 for vh would hit the same trap with no diagnostic trail.

Change

Three-part iframe height policy:

  1. Default: fill the parent panel (iframe.style.height = '100%' on mount). vh inside the iframe now resolves to the panel's stable height rather than the iframe's own runtime height, so an artifact using height: 68vh sizes to the panel as the author expected.
  2. Grow past the panel only when content genuinely exceeds it (long reports, database viewer after query results arrive). The parent Box still has overflow: auto and handles the scroll, preserving the dark-mode invert-filter workaround from 1e9a4e4 (scrollbar stays outside the CSS filter: invert() in dark mode).
  3. Loop guard: if scrollHeight grows again within one animation frame (100 ms) of our own size-set, treat it as vh feedback and skip. A one-shot console.warn fires 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 around wid_distribution_map.html (pre-workaround, height: 68vh):

Broken (before) Fixed (this PR)
iframe height 1315 px (frozen at browser loop guard) 804–871 px (stable)
Choropleth (g.geolayer) 593 × 308 (squished) 727 × 378 (correct 2:1 aspect)
Colorbar 75 × 809 (dominates viewport) 75 × 462 (normal)
Guard warning none one-shot console.warn when the feedback loop triggers

Screenshots 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 Biome noUnusedFunctionParameters warning on getLineCountFromHeader in src/server/agents/tools/bash.test.ts predates this branch and is unrelated.

Notes

  • The wid_distribution_map.html artifact 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 for vh gets a normal-looking result instead of a silent freeze.
  • No unit test added — the behavior needs a real iframe + ResizeObserver + measurable layout, and there's no existing jsdom scaffolding for that. Wrapper-level reproduction was used instead.

Copilot AI lite review requested due to automatic review settings August 7, 2026 01:34
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.
@diegoscarabelli
diegoscarabelli force-pushed the fix/artifact-viewer-vh-loop-guard branch from 8dadca4 to 11b0a84 Compare August 7, 2026 01:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.warn when a vh/%-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.

Comment on lines +275 to +279
if (scrollHeight !== lastSetHeight) {
iframe.style.height = `${scrollHeight}px`;
lastSetHeight = scrollHeight;
lastSetAt = performance.now();
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed in bec7c32.

  • setup() now calls observer?.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.body and parent, so a panel resize with unchanged body content still re-runs resizeToContent and reverts to height: 100% when the panel now fits the content.

Copilot AI review requested due to automatic review settings August 7, 2026 01:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copilot AI review requested due to automatic review settings August 7, 2026 01:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copilot AI review requested due to automatic review settings August 7, 2026 01:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] ArtifactViewer iframe auto-resize creates feedback loop with vh/% heights

2 participants