Skip to content

SCAL-338563 Reposition pre-rendered frames on any host scroll or layout shift - #684

Draft
sastaachar wants to merge 3 commits into
mainfrom
SCAL-338563
Draft

sastaachar wants to merge 3 commits into
mainfrom
SCAL-338563

Conversation

@sastaachar

Copy link
Copy Markdown
Contributor

Two customers, one defect: a pre-rendered liveboard frame stays pinned while the host page scrolls, so it overlaps page content and the liveboard can never be scrolled to its bottom.

Cause

The pre-render wrapper is position: absolute and is placed on its placeholder by syncPreRenderStyle(). It is re-synced by exactly two triggers: a scroll listener on a custom preRenderConfig.containerSelector, and a ResizeObserver on the placeholder.

Neither fires in the common case. With no containerSelector the wrapper is appended to document.body and its top is placeholderRect.y + window.scrollY. When the host app scrolls an inner element rather than the document — body never scrolls, so window.scrollY stays 0 — that top is frozen at wherever the placeholder was when it was last synced. Both reported apps have that shape.

Measured against a port of the current algorithm: scrolling an inner container 260px leaves the frame 260px out of place, exactly the scroll distance.

Three related gaps, each reported independently by one of the customers and each confirmed:

  • hidePreRender() never resets the wrapper's width/height, so a hidden frame keeps its full measured size at top: 0 and inflates the container's scrollable overflow.
  • showPreRender() seeds the placeholder from preRenderWrapper.style.height under fullHeight, which on a first reveal is still the 100vh that createPreRenderWrapper() wrote — a full-viewport overshoot frameParams cannot override.
  • Content growing above the frame displaces the placeholder without resizing it and without any scroll, so nothing re-syncs. Measured at 154px of drift even with containerSelector configured correctly.

Change

trackPreRenderPosition() replaces the container-only scroll listener and the placeholder-only ResizeObserver with the four signals that cover every way a placeholder moves, coalesced to one sync per animation frame:

  • scroll captured on window, which sees scrolls on any element in the page, so the host app's scrolling element needs no configuration;
  • resize;
  • a ResizeObserver on the placeholder and every ancestor up to the container;
  • observeElementMove() (new in utils.ts) — an IntersectionObserver framed on the placeholder's own rect, which fires on displacement with no size change.

hidePreRender() parks the wrapper with translateY(-100%): it keeps the measured size, so the next show still needs no resize, while adding no scrollable overflow. The fullHeight placeholder seed now uses a height the embedded app has actually reported.

The container scroll listener and reconcilePreRenderContainer()'s listener migration are removed as dead code. preRenderConfig.containerSelector keeps working and is no longer needed for correct positioning.

Regression range

syncPreRenderStyle() measured the host element directly until #517, which introduced the SDK-owned placeholder sized from frameParams and switched the measurement to it. createPreRenderPlaceholder is absent from published 1.48.0 and present in 1.49.0, which is the boundary the reporting customer crossed.

Verification

  • 7 new tests under position tracking (SCAL-338563). All 7 fail on unfixed source, all 7 pass with the change.
  • Full suite: 49 suites, 1986 passed, 4 skipped.
  • Re-verified in a browser: 0px desync on both scroll and content growth, with no containerSelector set.

package.json is on 1.52.1-beta.1 so the two customers can verify before release.

The pre-render wrapper is absolutely positioned and was re-synced by only two
triggers: a scroll listener on a custom containerSelector, and a ResizeObserver
on the placeholder. Neither fires for the common case. With no containerSelector
the wrapper sits in document.body and its top is placeholderRect.y +
window.scrollY, so when the host app scrolls an inner element — window.scrollY
stays 0 — that top is frozen at the placeholder's viewport position and the
frame stays pinned while the page scrolls under it.

trackPreRenderPosition() replaces both triggers with the four signals that cover
every way a placeholder moves: scroll captured on window, which sees scrolls on
any element so the scrolling element needs no configuration; resize; a
ResizeObserver on the placeholder and its ancestors; and observeElementMove, an
IntersectionObserver framed on the placeholder's own rect, which catches
displacement with no size change, such as content growing above the frame. Syncs
coalesce to one per animation frame.

hidePreRender() now parks the wrapper above the container's top edge, so a
hidden frame keeps its measured size without inflating scrollable overflow, and
the fullHeight placeholder seed uses a height the app has actually reported
rather than the 100vh createPreRenderWrapper() writes before anything is
measured.

SCAL-338563
@sastaachar
sastaachar requested a review from a team as a code owner September 23, 2026 15:57

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements position tracking for pre-rendered embeds to ensure the wrapper correctly follows the placeholder during scrolling or layout changes, addressing issues where the frame would remain pinned to the viewport. It introduces utility functions getPositioningAncestors and observeElementMove to monitor element displacement and updates TsEmbed to manage these tracking listeners. The reviewer provided a high-severity suggestion to throttle the refresh logic in observeElementMove to prevent layout thrashing and scroll jank.

Comment thread src/utils.ts
Comment on lines +853 to +916
export const observeElementMove = (
element: HTMLElement,
onMove: () => void,
): (() => void) => {
if (typeof IntersectionObserver === 'undefined') {
return () => undefined;
}

let observer: IntersectionObserver | null = null;
let stopped = false;
// Guards against the rebuild below re-entering through the initial
// callback every observer delivers on observe().
let isFirstCallback = true;

const refresh = () => {
if (stopped) {
return;
}
observer?.disconnect();

const rect = element.getBoundingClientRect();
const { innerHeight, innerWidth } = window;
// A zero-area element cannot be framed; wait for it to gain a size.
if (!rect.width || !rect.height) {
observer = null;
return;
}

const margins = [
-Math.floor(rect.top),
-Math.floor(innerWidth - rect.right),
-Math.floor(innerHeight - rect.bottom),
-Math.floor(rect.left),
];

isFirstCallback = true;
observer = new IntersectionObserver(
(entries) => {
const ratio = entries[0]?.intersectionRatio ?? 0;
if (isFirstCallback) {
isFirstCallback = false;
return;
}
if (ratio !== 1) {
onMove();
}
refresh();
},
{
rootMargin: margins.map((margin) => `${margin}px`).join(' '),
threshold: 1,
},
);
observer.observe(element);
};

refresh();

return () => {
stopped = true;
observer?.disconnect();
observer = null;
};
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Recreating the IntersectionObserver and calling getBoundingClientRect() on every single scroll/movement frame causes severe layout thrashing and scroll jank. Since scroll events are already captured on window and trigger the sync, we should throttle the refresh() call inside observeElementMove to avoid constant observer recreation during active scrolling.

export const observeElementMove = (
    element: HTMLElement,
    onMove: () => void,
): (() => void) => {
    if (typeof IntersectionObserver === 'undefined') {
        return () => undefined;
    }

    let observer: IntersectionObserver | null = null;
    let stopped = false;
    // Guards against the rebuild below re-entering through the initial
    // callback every observer delivers on observe().
    let isFirstCallback = true;
    let timeoutId: any = null;

    const refresh = () => {
        if (stopped) {
            return;
        }
        observer?.disconnect();

        const rect = element.getBoundingClientRect();
        const { innerHeight, innerWidth } = window;
        // A zero-area element cannot be framed; wait for it to gain a size.
        if (!rect.width || !rect.height) {
            observer = null;
            return;
        }

        const margins = [
            -Math.floor(rect.top),
            -Math.floor(innerWidth - rect.right),
            -Math.floor(innerHeight - rect.bottom),
            -Math.floor(rect.left),
        ];

        isFirstCallback = true;
        observer = new IntersectionObserver(
            (entries) => {
                const ratio = entries[0]?.intersectionRatio ?? 0;
                if (isFirstCallback) {
                    isFirstCallback = false;
                    return;
                }
                if (ratio !== 1) {
                    onMove();
                }
                // Throttle the observer recreation to avoid layout thrashing
                // and high CPU usage during continuous updates (like scrolling).
                if (!timeoutId) {
                    timeoutId = setTimeout(() => {
                        timeoutId = null;
                        refresh();
                    }, 150);
                }
            },
            {
                rootMargin: margins.map((margin) => `${margin}px`).join(' '),
                threshold: 1,
            },
        );
        observer.observe(element);
    };

    refresh();

    return () => {
        stopped = true;
        observer?.disconnect();
        observer = null;
        if (timeoutId) {
            clearTimeout(timeoutId);
        }
    };
};

…observer thrashing

Two follow-ups from testing the positioning fix against the reported cases.

The wrapper is not a descendant of the host's scrolling box — by default it is a
document.body sibling — so that box cannot clip it. Positioned correctly it
still paints over a sticky nav or a panel edge the moment its placeholder
scrolls underneath one, which is what a frame "moving above the nav" looks like.
syncPreRenderStyle now reproduces the clip its placeholder would get in flow,
via getClipInsetForElement and the clipping ancestors the SDK already tracks.

observeElementMove re-armed inside its own callback, so a continuous scroll
allocated an IntersectionObserver every frame and read layout with it, to report
movement the window scroll listener had already handled. The re-frame is now
deferred until movement settles; because the element can move while disarmed,
each re-frame compares against the rect it last framed and reports what it
missed.

SCAL-338563
@sastaachar

Copy link
Copy Markdown
Contributor Author

Second defect found while testing this against the reported cases

Positioning was only half of it. The wrapper is a document.body sibling, so the host's scrolling box is not its ancestor and cannot clip it. Placed perfectly it still paints over a sticky nav the moment its placeholder scrolls underneath one.

Measured in a browser at the same deep scroll, wrapper raw top 34px in both cases:

wrapper lives in visible from escapes the container?
inside the scroller 100px — the container's top edge no
document.body (the default) 34px yes, 48px past the edge

The second row is with this PR's position tracking already applied: desync: 0, the frame is glued to its placeholder, and it still overruns. That is what "the iframe moves above the nav" looks like, and it is the symptom one of the two reporting customers actually has.

syncPreRenderStyle() now reproduces the clip the placeholder would get in flow, via getClipInsetForElement() over the getEffectiveClippingAncestors() the SDK already computes for full-height lazy loading.

One sharp edge worth knowing for anyone writing tests here: getEffectiveClippingAncestors() seeds its clip rect with the viewport, so a scroll container flush with the viewport top is judged redundant and dropped. It only reports containers that clip beyond what the viewport already does. Correct for the real shape (a panel below a nav), surprising if you put the container at y: 0 in a test.

Also in this push

observeElementMove re-armed inside its own callback, so a continuous scroll allocated an IntersectionObserver every frame and read layout with it — to report movement the window scroll listener had already handled. The re-frame is now deferred until movement settles. Because the element can still move while disarmed, each re-frame compares against the rect it last framed and reports anything it missed.

Suite: 49 suites, 1995 passed, 4 skipped.

@sastaachar
sastaachar marked this pull request as draft September 24, 2026 10:20
@sastaachar

Copy link
Copy Markdown
Contributor Author

Now reproduced against the real SDK, with primary evidence

Previously this PR was reasoned from source plus a synthetic port of the positioning arithmetic. It is now backed by a capture of the actual failing customer page and a replica driven by the real SDK.

From the customer's captured DOM:

  • They do use pre-render (tsEmbed-pre-render-wrapper/-placeholder/-child, preRenderId: THOUGHTSPOT_PRE_RENDER_ID).
  • data-ts-embed-original-position appears nowhere, so applyPreRenderContainerPositioning() never ran — no custom container, wrapper is a document.body child.
  • Placeholder height: 4597px; wrapper top: 265.797px; height: 680px. The frame is 3917px shorter than its slot, at a frozen viewport coordinate.

Replica: their layout — fixed nav, inner overflow-y: scroll panel, same nested slot — with the SDK loaded from jsDelivr. No cluster is needed: authType: None against an unreachable host means the iframe never loads, and every line of this defect is host-side DOM work.

Grow the placeholder to 4597px (what setIFrameHeight does on EmbedHeight), then scroll the panel 1400px:

measurement 1.52.1 this branch
drift 1400px — the frame never moves 0px
height shortfall 3917px, ResizeObserver self-heals late 0px
painted top vs nav bottom frozen below the nav 64px vs 64px — clipped exactly at the edge

The 3917px shortfall matches the production capture exactly, so the replica is faithful rather than approximate.

Mechanism, confirmed rather than inferred. The only re-sync trigger is the ResizeObserver on the placeholder. It fires on size changes and incidentally corrects position too, which is why the height self-heals. A pure scroll changes no size, so nothing fires and the frame sits frozen — drift equals the scroll distance exactly. Once any sync does run, the wrapper lands at the placeholder's viewport coordinates with no clipping and then overruns the nav (measured at 403px).

One correction to this PR's earlier description: the "Regression range" section naming #517 is wrong and I'll remove it. The frozen top, the document.body parent and the absent scroll listener were all already present in 1.47.2. #517 changed which element is measured, which altered how the bug presents, but it did not introduce it. There is no single causing PR.

@sastaachar

Copy link
Copy Markdown
Contributor Author

Verified end-to-end on a live cluster

Previous evidence was a stub whose iframe never loaded. This is a real Liveboard rendering real charts from a real cluster, laid out the way the reporting customer's page is: fixed nav, inner overflow-y: scroll panel, fullHeight: true + preRenderId.

fullHeight reported 4470px, against the 4597px in the customer's captured DOM — same shape, same scale.

scroll 1.52.1 drift this branch branch painted top vs nav branch clip
0 0 0 261 none
600px 600 0 64 vs 64 inset(403px …)
1500px 1500 0 64 vs 64 inset(1303px …)

On 1.52.1 the frame never moves: drift equals the scroll distance exactly, at every position. On this branch drift stays 0 and the frame is clipped to the nav's lower edge, the clip growing as it scrolls under.

Both defects this PR addresses are now confirmed against a live embed rather than a simulation, and both are fixed.

This branch has not been deployed

No deployments
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.

1 participant