Skip to content

SCAL-338563 Place the pre-rendered frame in the host's scrolling ancestor - #684

Open
sastaachar wants to merge 12 commits into
mainfrom
SCAL-338563
Open

sastaachar wants to merge 12 commits into
mainfrom
SCAL-338563

Conversation

@sastaachar

@sastaachar sastaachar commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

A pre-rendered frame does not follow the page when the host scrolls an inner element rather than the document. Reported by two customers, reproduced on a live cluster against both their layouts.

Cause

The wrapper is absolutely positioned and defaulted into document.body, with top computed as placeholderRect.y + window.scrollY.

That is correct whenever the document is the scroller: the containing block moves with the page, the browser carries the frame, and no listener is needed. It is how pre-render was built, and it covers most pages.

It breaks the moment the host scrolls an inner element. window.scrollY is then permanently 0, so top is a viewport coordinate that is stale as soon as anything scrolls, and the frame sits still while the page moves under it.

Measured on a live cluster. Drift equals the scroll distance exactly:

host layout 1.47.2 1.52.1 this PR
fixed left nav + top nav, right pane scrolls 1200px @1200 1200px @1200 0
top nav, content scrolls beneath it 1200px @1200 1200px @1200 0

This is not a regression, and "it worked before" is also true

1.47.2 drifts identically, so there is no version to roll back to — pre-render has never worked in a host that scrolls an inner element.

Both customers nevertheless report it working before, and they are right. Neither was using pre-render before. One customer's patch adds warm-liveboard.ts as a new file with every preRenderId line an addition: they adopted pre-render in the same change as the SDK upgrade. Their "before" is no pre-render, which has no bug to speak of — the iframe is in normal flow, it scrolls with the page, and wheel events chain out of it.

So the upgrade did not break this. Turning on pre-render did, and the version changed in the same commit.

Change

Resolve the default container to the host element's nearest scrolling ancestor, falling back to document.body when nothing scrolls. preRenderConfig.containerSelector still overrides.

Nearest is the correct choice, including nested scrollers: the wrapper goes in the innermost one and is positioned against it, so scrolling that element moves it through that element's content, and scrolling anything further out moves the whole box with the wrapper inside it.

On the container outliving the embed — the reason the default was document.body — a scrolling ancestor is layout chrome rather than route content, and it is an ancestor of the host element, so if it goes the host element goes with it and the embed is remounting anyway. reconcilePreRenderContainer re-runs the resolution on a detached container.

Being inside the scroller fixes a second symptom for free. Wheel events chain out of an iframe into the nearest scrollable ancestor of its wrapper; from document.body there is nothing to chain into, so a full-height liveboard swallowed them and the page would not scroll with the cursor over the frame. Measured: host scroll 300 → 800 with this change, unmoved without it.

Three smaller fixes on the same path:

  • showPreRender now sets pointer-events: auto instead of removing the property. A container carrying pointer-events: none — the usual styling for a parking root that must not swallow clicks — passes it down, and dropping the property left the inherited none in force with the frame dead to input. One customer had already worked around this with a > * { pointer-events: auto } rule.
  • hidePreRender parks the wrapper, so a hidden frame stops contributing scrollable area. It previously kept its full measured size at top: 0: verified on a live cluster, a hidden frame held the container's scrollHeight at 7312px with nothing visible in it.
  • The fullHeight placeholder seed ignores the 100vh that createPreRenderWrapper() writes before anything is measured, so a first reveal no longer overshoots to a full viewport.

Deliberately not done

An earlier revision repositioned the frame on scroll — window-capture scroll, a rAF coalescer, an IntersectionObserver move detector. It worked, but spent a layout read and a style write per animation frame to compensate for the frame being in the wrong box. Removed. Position is the browser's job once the frame is placed correctly; the only thing it will not do for us is resize the wrapper when the placeholder's height changes, which is what the remaining ResizeObserver is for.

Clipping the frame to its container is also out of scope — escaping a container is a z-index concern for the host app, not something pre-render guaranteed.

Effect on the two reports

Neither customer needs a host-app change. Verified with one customer's layout and their workaround removed entirely: drift 0 at every scroll position.

Suite: 49 suites, 1986 passed.

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 Outdated
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.

The build job fails on stale static/typedoc/typedoc.json, and guard-version-bump
rejects a version edit made in a PR — that repo uses the bumpVersion workflow.
Reverting the bump lets CI go green, which is also what publishes the
pkg.pr.new preview build.

SCAL-338563
@pkg-pr-new

pkg-pr-new Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@thoughtspot/visual-embed-sdk@684

commit: 5cd77b1

Reworked from the earlier approach. Repositioning a body-level frame on every
scroll meant a layout read and a style write per animation frame, to compensate
for the frame being in the wrong box in the first place. Removed: the rAF
coalescer, the window-capture scroll listener, the IntersectionObserver move
detector, and the clip-path work.

An absolutely positioned wrapper follows the page for free when it sits inside
whatever scrolls. document.body is right whenever the document is the scroller,
which is most pages and is what pre-render was built against. An app that
scrolls an inner element has to name it with preRenderConfig.containerSelector,
and there is nothing the SDK can do from document.body without per-scroll work.

So the default stays, and the SDK now warns once, naming the scrolling element
it found, when an embed sits inside one and no containerSelector is set. That is
the difference between the two reports: one customer found the option, the other
had no signal it existed.

Also kept from the earlier work: hidePreRender parks the wrapper so a hidden
frame stops adding scrollable area, 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 marked this pull request as ready for review September 28, 2026 10:39
@sastaachar sastaachar changed the title SCAL-338563 Reposition pre-rendered frames on any host scroll or layout shift SCAL-338563 Warn when a pre-rendered frame sits in a scrolling element with no container set Sep 28, 2026
The wrapper is absolutely positioned, so it follows the page for free — but only
while it sits inside whatever scrolls. It defaulted to document.body, which is
right only when the document itself is the scroller. An app that scrolls an
inner element instead left the frame pinned to the viewport while the page moved
under it, because window.scrollY never changes there and the position it was
given is a viewport coordinate that is stale immediately.

So resolve the default container to the host element's nearest scrolling
ancestor, falling back to document.body when nothing scrolls. A scrolling
ancestor is layout chrome rather than route content, so it outlives the embed;
and if it does go, the host element goes with it and the embed is remounting
anyway. reconcilePreRenderContainer re-runs the resolution on a detached
container. preRenderConfig.containerSelector still overrides.

Being inside the scroller also fixes wheel scrolling over the frame: events
chain out of the iframe into the element that scrolls, which they cannot do from
document.body, so a full-height liveboard used to swallow them.

showPreRender now sets pointer-events rather than removing it. A container
carrying pointer-events:none passes it down, and dropping the property left the
inherited none in force with the frame dead to input.

Also: hidePreRender parks the wrapper so a hidden frame adds no scrollable area,
and the fullHeight placeholder seed ignores the 100vh createPreRenderWrapper
writes before anything is measured.

SCAL-338563
@sastaachar sastaachar changed the title SCAL-338563 Warn when a pre-rendered frame sits in a scrolling element with no container set SCAL-338563 Place the pre-rendered frame in the host's scrolling ancestor Sep 28, 2026
…ual config

React constructs a new LiveboardEmbed on every mount, so the previous === this
check never matched and every remount of an unchanged pre-rendered liveboard
routed via home and reloaded. Compare viewConfig instead, treating callbacks
as equal.

SCAL-336321
isSameEmbedConfig compared viewConfig objects directly, so it never returned
true after the previous instance had been shown once. navigateToLiveboard
assigns vizId, activeTabId and personalizedViewId unconditionally, leaving them
as undefined-valued keys; a freshly constructed instance does not have those
keys at all, and isEqual counts own keys. The comparison therefore failed on
configs that were identical in every value — which is the exact case it exists
to detect.

Measured against a live cluster, three re-mounts with an unchanged config:
3 home hops on 1.52.1, 2 with the comparison as written, 0 with this. A changed
runtime filter still routes via home, as it must.

The existing tests could not catch this: both instances are constructed with
vizId and activeTabId passed explicitly, so the asymmetry never arises. Added a
test that reproduces it by assigning the keys as undefined on the previous
instance, the way navigateToLiveboard does.

SCAL-336321
@sastaachar

Copy link
Copy Markdown
Contributor Author

The React re-mount fix did not work as written — now fixed

Verified against a live cluster and independently reproduced in a sandbox demo. isSameEmbedConfig never returned true after the previous instance had been shown once, so the home hop it was meant to remove still happened.

Cause. navigateToLiveboard() assigns all four route keys unconditionally:

this.viewConfig.activeTabId = activeTabId;              // undefined
this.viewConfig.vizId = vizId;                          // undefined
this.viewConfig.personalizedViewId = personalizedViewId; // undefined

An instance that has been shown once therefore carries three undefined-valued keys that a freshly constructed one does not have at all — 10 own keys against 7. isEqual counts own keys, so { a: undefined } and {} are not equal, and the comparison failed on configs identical in every value.

Measured on a live cluster, three re-mounts with an unchanged config:

build home hops
1.52.1 stock 3
isSameEmbedConfig as written 2
with definedEntries 0

The one hop it did save was the previous === this early return, which already worked before the change.

Why the tests passed anyway. Both instances in the existing tests are constructed with { liveboardId, vizId, activeTabId, ...defaultViewConfig }, so the keys are present on both and the asymmetry never arises. They assert the right behaviour against a config shape that cannot occur in the React path they model. Added a test that assigns the keys as undefined on the previous instance the way navigateToLiveboard does; it fails without this change.

The control still behaves: a changed runtimeFilters routes via home exactly as before.

Suite: 49 suites, 1989 passed.

flushAnimationFrame was there for the coalescer that is gone; syncs are
synchronous again. The hidden-frame test asserted syncPreRenderStyle is not
called on a scroll event, which now passes whether the frame is hidden or shown
because nothing listens for scroll — replaced with the contract that does exist,
that hiding releases the placeholder observer.

SCAL-338563

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