From 39ea0fe1dc05c495dea2f70e59cc94b036e15f4f Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Wed, 26 Aug 2026 20:51:42 +0530 Subject: [PATCH 1/8] SCAL-334772 Extract full-height support into a shared module --- src/embed/app.spec.ts | 60 +++++-- src/embed/app.ts | 284 +++----------------------------- src/embed/liveboard.spec.ts | 22 +-- src/embed/liveboard.ts | 312 +++--------------------------------- src/embed/ts-embed.ts | 27 +--- src/full-height.spec.ts | 274 +++++++++++++++++++++++++++++++ src/full-height.ts | 307 +++++++++++++++++++++++++++++++++++ src/types.ts | 161 +++++++++++++++++++ src/utils.ts | 38 +++++ 9 files changed, 890 insertions(+), 595 deletions(-) create mode 100644 src/full-height.spec.ts create mode 100644 src/full-height.ts diff --git a/src/embed/app.spec.ts b/src/embed/app.spec.ts index b761df10..0120cc57 100644 --- a/src/embed/app.spec.ts +++ b/src/embed/app.spec.ts @@ -55,7 +55,7 @@ const testUrlParams = async (viewConfig: AppViewConfig, expectedUrl: string) => }); }; -// Helper function to test setIframeHeightForNonEmbedLiveboard behavior +// Helper function to test the full-height route-change behavior const testSetIframeHeightBehavior = ( currentPath: string, shouldBeCalled: boolean @@ -70,7 +70,7 @@ const testSetIframeHeightBehavior = ( : jest.spyOn(appEmbed, 'setIFrameHeight'); appEmbed.render(); - appEmbed.setIframeHeightForNonEmbedLiveboard({ + appEmbed.fullHeightController.handleRouteChange({ data: { currentPath }, type: 'Route', }); @@ -1851,6 +1851,7 @@ describe('App embed tests', () => { test('should register event handlers to adjust iframe height', async () => { let embedHeightCallback: any = () => { }; + let embedIframeCenterCallback: any = () => { }; const onSpy = jest.spyOn(AppEmbed.prototype, 'on').mockImplementation((event, callback) => { if (event === EmbedEvent.RouteChange) { callback({ type: EmbedEvent.RouteChange, data: { currentPath: '/answers' } } as any, jest.fn()); @@ -1859,11 +1860,10 @@ describe('App embed tests', () => { embedHeightCallback = callback; } if (event === EmbedEvent.EmbedIframeCenter) { - callback({ type: EmbedEvent.EmbedIframeCenter, data: {} } as any, jest.fn()); + embedIframeCenterCallback = callback; } return null; }); - jest.spyOn(TsEmbed.prototype as any, 'getIframeCenter').mockReturnValue({}); jest.spyOn(TsEmbed.prototype as any, 'setIFrameHeight').mockReturnValue({}); const appEmbed = new AppEmbed(getRootEl(), { ...defaultViewConfig, @@ -1879,13 +1879,27 @@ describe('App embed tests', () => { await appEmbed.render(); embedHeightCallback({ data: '100%' }); + // The app only asks for the iframe center once the iframe exists. + const centerResponder = jest.fn(); + embedIframeCenterCallback( + { type: EmbedEvent.EmbedIframeCenter, data: {} } as any, + centerResponder, + ); + // Verify event handlers were registered await executeAfterWait(() => { expect(onSpy).toHaveBeenCalledWith(EmbedEvent.EmbedHeight, expect.anything()); expect(onSpy).toHaveBeenCalledWith(EmbedEvent.RouteChange, expect.anything()); expect(onSpy).toHaveBeenCalledWith(EmbedEvent.EmbedIframeCenter, expect.anything()); expect(onSpy).toHaveBeenCalledWith(EmbedEvent.RequestVisibleEmbedCoordinates, expect.anything()); + expect(centerResponder).toHaveBeenCalledWith( + expect.objectContaining({ type: EmbedEvent.EmbedIframeCenter }), + ); }, 100); + + // This test replaces AppEmbed.prototype.on; restore it so the mock does + // not leak into the tests that follow. + jest.restoreAllMocks(); }); describe('Navigate to Page API', () => { @@ -2455,7 +2469,7 @@ describe('App embed tests', () => { await appEmbed.render(); // Trigger the lazy load data calculation - (appEmbed as any).sendFullHeightLazyLoadData(); + (appEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 0, @@ -2477,7 +2491,7 @@ describe('App embed tests', () => { await appEmbed.render(); // Trigger the lazy load data calculation - (appEmbed as any).sendFullHeightLazyLoadData(); + (appEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).not.toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 0, @@ -2510,7 +2524,7 @@ describe('App embed tests', () => { await appEmbed.render(); // Trigger the lazy load data calculation - (appEmbed as any).sendFullHeightLazyLoadData(); + (appEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 50, // 50px clipped from top @@ -2618,7 +2632,7 @@ describe('App embed tests', () => { const mockResponder = jest.fn(); // Trigger the handler directly - (appEmbed as any).requestVisibleEmbedCoordinatesHandler({}, mockResponder); + (appEmbed as any).fullHeightController.handleRequestVisibleCoordinates({}, mockResponder); // Verify the responder was called with the correct data expect(mockResponder).toHaveBeenCalledWith({ @@ -2676,7 +2690,7 @@ describe('App embed tests', () => { data: 600, type: EmbedEvent.EmbedHeight, }; - appEmbed.updateIFrameHeight(mockEvent); + appEmbed.fullHeightController.handleEmbedHeight(mockEvent); // Check if the iframe style was updated expect(mockIFrame.style.height).toBe('600px'); @@ -2697,7 +2711,7 @@ describe('App embed tests', () => { data: 0, // This will make it use the default height type: EmbedEvent.EmbedHeight, }; - appEmbed.updateIFrameHeight(mockEvent); + appEmbed.fullHeightController.handleEmbedHeight(mockEvent); // Should use the default height expect(mockIFrame.style.height).toBe('500px'); @@ -2712,7 +2726,7 @@ describe('App Embed Default Height and Minimum Height Handling', () => { fullHeight: true, } as AppViewConfig); await appEmbed.render(); - expect(appEmbed['defaultHeight']).toBe(500); + expect(appEmbed['fullHeightController'].minimumHeight).toBe(500); }); test('should set default height to 700 when default height is provided', async () => { const appEmbed = new AppEmbed(getRootEl(), { @@ -2721,7 +2735,7 @@ describe('App Embed Default Height and Minimum Height Handling', () => { minimumHeight: 700, } as AppViewConfig); await appEmbed.render(); - expect(appEmbed['defaultHeight']).toBe(700); + expect(appEmbed['fullHeightController'].minimumHeight).toBe(700); }); }); @@ -2807,7 +2821,25 @@ describe('AppEmbed uncovered branch tests', () => { }); }); - test('registerLazyLoadEvents should return early when iFrame is not set', () => { + test('protected updateIFrameHeight still floors at the configured minimum', async () => { + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + minimumHeight: 800, + } as AppViewConfig); + await appEmbed.render(); + const setHeight = jest + .spyOn(appEmbed as any, 'setIFrameHeight') + .mockImplementation(jest.fn()); + + (appEmbed as any).updateIFrameHeight({ data: 300 }); + expect(setHeight).toHaveBeenCalledWith(800); + + (appEmbed as any).updateIFrameHeight({ data: 1200 }); + expect(setHeight).toHaveBeenCalledWith(1200); + }); + + test('lazy load registration should return early when iFrame is not set', () => { const appEmbed = new AppEmbed(getRootEl(), { ...defaultViewConfig, fullHeight: true, @@ -2815,7 +2847,7 @@ describe('AppEmbed uncovered branch tests', () => { } as AppViewConfig); // iFrame is not set (render not called), should not throw expect(() => { - (appEmbed as any).registerLazyLoadEvents(); + (appEmbed as any).fullHeightController.onRender(); }).not.toThrow(); }); }); diff --git a/src/embed/app.ts b/src/embed/app.ts index 8058b5d1..10125fe9 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -9,19 +9,20 @@ */ import { logger } from '../utils/logger'; -import { calculateVisibleElementData, getEffectiveClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; -import { DEFAULT_LAZY_LOADING_MARGIN } from '../config'; +import { getQueryParamString, isUndefined, setParamIfDefined } from '../utils'; import { Param, DOMSelector, HostEvent, EmbedEvent, - MessagePayload, AllEmbedViewConfig, + MessagePayload, + FullHeightViewConfig, DefaultAppInitData, VisualizationOverrides, SpotterFileUploadFileTypes, } from '../types'; +import { FullHeightController } from '../full-height'; import { V1Embed } from './ts-embed'; import { SpotterChatViewConfig, SpotterSidebarViewConfig, SpotterQueryMode, SpotterShareConversationConfig, StarterPromptsConfig } from './conversation'; import { buildSpotterSidebarAppInitData, buildSpotterShareConversationAppInitData, buildStarterPromptsAppInitData } from './spotter-utils'; @@ -179,7 +180,7 @@ export interface DiscoveryExperience { * The view configuration for full app embedding. * @group Embed components */ -export interface AppViewConfig extends AllEmbedViewConfig { +export interface AppViewConfig extends AllEmbedViewConfig, FullHeightViewConfig { /** * If true, the top navigation bar within the ThoughtSpot app * is displayed. By default, the navigation bar is hidden. @@ -464,34 +465,6 @@ export interface AppViewConfig extends AllEmbedViewConfig { * ``` */ enableSearchAssist?: boolean; - /** - * If set to true, the Liveboard container dynamically resizes - * according to the height of the Liveboard. - * - * **Note**: Using fullHeight loads all visualizations - * on the Liveboard simultaneously, which results in - * multiple warehouse queries and potentially a - * longer wait for the topmost visualizations to - * display on the screen. Setting fullHeight to - * `false` fetches visualizations incrementally as - * users scroll the page to view the charts and tables. - * - * From SDK 1.52.0, enabling `fullHeight` also turns on - * {@link lazyLoadingForFullHeight} and - * {@link enableScrollableContainerLazyLoading}, so visualizations load as - * they scroll into view. Set either flag to `false` to opt out. - * - * Supported embed types: `AppEmbed` - * @version SDK: 1.21.0 | ThoughtSpot: 9.4.0.cl, 9.4.0-sw - * @example - * ```js - * const embed = new AppEmbed('#tsEmbed', { - * ... // other embed view config - * fullHeight: true, - * }) - * ``` - */ - fullHeight?: boolean; /** * Enables the V2 navigation and modular home page experience. * For more information, @@ -693,79 +666,7 @@ export interface AppViewConfig extends AllEmbedViewConfig { */ isGranularXLSXCSVSchedulesEnabled?: boolean; - /** - * Loads visualizations only as they scroll into the viewport, instead of - * loading the whole full-height Liveboard at once. - * - * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is - * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be - * set explicitly. Set it to `false` to load every visualization upfront. - * The flag has no effect unless `fullHeight` is enabled. - * - * @type {boolean} - * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl - * @default true when `fullHeight` is enabled, from SDK 1.52.0 - * @example - * ```js - * const embed = new AppEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * }) - * ``` - */ - lazyLoadingForFullHeight?: boolean; - /** - * Computes the visible region of the embed against its scrollable and - * clipping ancestors, instead of treating the browser window as the only - * viewport, and tracks scroll and resize on those ancestors. - * - * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is - * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be - * set explicitly. Set it to `false` when the page scrolls with the window - * and the embed has no clipping ancestor, to skip the extra ancestor - * tracking. - * - * @type {boolean} - * @default true when `fullHeight` is enabled, from SDK 1.52.0 - * @hidden - */ - enableScrollableContainerLazyLoading?: boolean; - /** - * How far outside the viewport a visualization starts loading, when - * {@link lazyLoadingForFullHeight} is enabled. - * - * For example, if the margin is set to '10px', - * the visualization will be loaded 10px before its top edge is visible in the - * viewport. - * - * The format is similar to CSS margin, so `'500px 0px'` extends the - * prefetch 500px above and below the viewport and not sideways. Accepted - * units are `px`, `em`, `rem`, `%`, `vh` and `vw`, plus bare `0` and - * `auto`; an invalid value is logged and ignored. - * - * From SDK 1.52.0 this defaults to `'500px 0px'` — roughly one - * visualization ahead of the scroll position, so a chart has usually - * finished loading by the time it scrolls into view. Use a smaller margin - * to cut warehouse queries further, or `'0px'` to load a visualization - * only once it is actually visible. - * - * @type {string} - * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl - * @default '500px 0px' when `fullHeight` is enabled, from SDK 1.52.0 - * @example - * ```js - * const embed = new AppEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * // Using 0px, the visualization will be only loaded when it's visible in the viewport. - * lazyLoadingMargin: '0px', - * }) - * ``` - */ - lazyLoadingMargin?: string; /** * updatedSpotterChatPrompt : Controls the updated spotter chat prompt. @@ -954,23 +855,6 @@ export interface AppViewConfig extends AllEmbedViewConfig { * @default false */ enableStopAnswerGenerationEmbed?: boolean; - /** - * This is the minimum height (in pixels) for a full-height App. - * Setting this height helps resolve issues with empty Apps and - * other screens navigable from an App. - * - * @version SDK: 1.44.2 | ThoughtSpot: 10.15.0.cl - * @default 500 - * @example - * ```js - * const embed = new AppEmbed('#embed', { - * ... // other app view config - * fullHeight: true, - * minimumHeight: 600, - * }); - * ``` - */ - minimumHeight?: number; /** * To enable the homepage announcement banner. * Controls the visibility of the announcement section @@ -1044,34 +928,22 @@ export interface AppEmbedAppInitData extends DefaultAppInitData { export class AppEmbed extends V1Embed { protected viewConfig: AppViewConfig; - private defaultHeight = 500; - - private lazyLoadScrollContainers: HTMLElement[] = []; - - private lazyLoadResizeObserver: ResizeObserver | undefined; + private readonly fullHeightController: FullHeightController; constructor(domSelector: DOMSelector, viewConfig: AppViewConfig) { viewConfig.embedComponentType = 'AppEmbed'; super(domSelector, viewConfig); - if (this.viewConfig.fullHeight === true) { - if (this.viewConfig.lazyLoadingForFullHeight === undefined) { - this.viewConfig.lazyLoadingForFullHeight = true; - } - if (this.viewConfig.enableScrollableContainerLazyLoading === undefined) { - this.viewConfig.enableScrollableContainerLazyLoading = true; - } - if (this.viewConfig.lazyLoadingMargin === undefined) { - this.viewConfig.lazyLoadingMargin = DEFAULT_LAZY_LOADING_MARGIN; - } - - this.on(EmbedEvent.RouteChange, this.setIframeHeightForNonEmbedLiveboard); - this.on(EmbedEvent.EmbedHeight, this.updateIFrameHeight); - this.on(EmbedEvent.EmbedIframeCenter, this.embedIframeCenter); - this.on( - EmbedEvent.RequestVisibleEmbedCoordinates, - this.requestVisibleEmbedCoordinatesHandler, - ); - } + this.fullHeightController = new FullHeightController(this.viewConfig, { + getIframe: () => this.iFrame, + setFrameHeight: (height) => this.setIFrameHeight(height), + on: (eventType, callback) => { + this.on(eventType, callback); + }, + trigger: (hostEvent, data) => { + this.trigger(hostEvent, data); + }, + }); + this.fullHeightController.registerEventHandlers(); } /** @@ -1122,7 +994,6 @@ export class AppEmbed extends V1Embed { hideOrgSwitcher, enableSearchAssist, newConnectionsExperience, - fullHeight, dataPanelV2 = true, updatedSpotterExperience, hideLiveboardHeader = false, @@ -1165,7 +1036,6 @@ export class AppEmbed extends V1Embed { enableStopAnswerGenerationEmbed, spotterChatConfig, spotterDataSources, - minimumHeight, isThisPeriodInDateFiltersEnabled, enableHomepageAnnouncement = false, isContinuousLiveboardPDFEnabled, @@ -1270,15 +1140,7 @@ export class AppEmbed extends V1Embed { params[Param.HideNotification] = !!hideNotification; } - if (fullHeight === true) { - params[Param.fullHeight] = true; - if (this.viewConfig.lazyLoadingForFullHeight) { - params[Param.IsLazyLoadingForEmbedEnabled] = true; - if (isValidCssMargin(this.viewConfig.lazyLoadingMargin)) { - params[Param.RootMarginForLazyLoad] = this.viewConfig.lazyLoadingMargin; - } - } - } + this.fullHeightController.addQueryParams(params); if (tag) { params[Param.Tag] = tag; @@ -1371,8 +1233,6 @@ export class AppEmbed extends V1Embed { params[Param.IsWYSIWYGLiveboardPDFEnabled] = isContinuousLiveboardPDFEnabled; } - this.defaultHeight = minimumHeight || this.defaultHeight; - if (enableLiveboardDataCache !== undefined) { params[Param.EnableLiveboardDataCache] = enableLiveboardDataCache; } @@ -1428,32 +1288,6 @@ export class AppEmbed extends V1Embed { return params; } - private sendFullHeightLazyLoadData = () => { - const data = calculateVisibleElementData( - this.iFrame, - this.viewConfig.enableScrollableContainerLazyLoading, - ); - // this should be fired only if the lazyLoadingForFullHeight and fullHeight are true - if(this.viewConfig.lazyLoadingForFullHeight && this.viewConfig.fullHeight){ - this.trigger(HostEvent.VisibleEmbedCoordinates, data); - } - } - - /** - * This is a handler for the RequestVisibleEmbedCoordinates event. - * It is used to send the visible coordinates data to the host application. - * @param data The event payload - * @param responder The responder function - */ - private requestVisibleEmbedCoordinatesHandler = (data: MessagePayload, responder: any) => { - logger.info('Sending RequestVisibleEmbedCoordinates', data); - const visibleCoordinatesData = calculateVisibleElementData( - this.iFrame, - this.viewConfig.enableScrollableContainerLazyLoading, - ); - responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); - } - /** * Constructs the URL of the ThoughtSpot app page to be rendered. * @param pageId The ID of the page to be embedded. @@ -1472,39 +1306,13 @@ export class AppEmbed extends V1Embed { /** * Set the iframe height as per the computed height received * from the ThoughtSpot app. + * + * Retained for subclasses: the full-height controller drives this + * internally, so overriding it does not intercept the embed event. * @param data The event payload */ - protected updateIFrameHeight = (data: MessagePayload) => { - this.setIFrameHeight(Math.max(data.data, this.defaultHeight)); - this.sendFullHeightLazyLoadData(); - }; - - private embedIframeCenter = (data: MessagePayload, responder: any) => { - const obj = this.getIframeCenter(); - responder({ type: EmbedEvent.EmbedIframeCenter, data: obj }); - }; - - private setIframeHeightForNonEmbedLiveboard = (data: MessagePayload) => { - const { height: frameHeight } = this.viewConfig.frameParams || {}; - - const liveboardRelatedRoutes = [ - '/pinboard/', - '/insights/pinboard/', - '/schedules/', - '/embed/viz/', - '/embed/insights/viz/', - '/liveboard/', - '/insights/liveboard/', - '/tsl-editor/PINBOARD_ANSWER_BOOK/', - '/import-tsl/PINBOARD_ANSWER_BOOK/', - ]; - - if (liveboardRelatedRoutes.some((path) => data.data.currentPath.startsWith(path))) { - // Ignore the height reset of the frame, if the navigation is - // only within the liveboard page. - return; - } - this.setIFrameHeight(frameHeight || this.defaultHeight); + protected updateIFrameHeight = (data: MessagePayload): void => { + this.setIFrameHeight(Math.max(data.data, this.fullHeightController.minimumHeight)); }; /** @@ -1596,53 +1404,11 @@ export class AppEmbed extends V1Embed { */ public destroy() { super.destroy(); - this.unregisterLazyLoadEvents(); + this.fullHeightController.destroy(); } private postRender() { - this.registerLazyLoadEvents(); - } - - private registerLazyLoadEvents() { - if (!this.iFrame) { - return; - } - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - this.unregisterLazyLoadEvents(); - // TODO: Use passive: true, install modernizr to check for passive - window.addEventListener('resize', this.sendFullHeightLazyLoadData); - window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); - if (!this.viewConfig.enableScrollableContainerLazyLoading) { - return; - } - this.lazyLoadScrollContainers = getScrollableAncestors(this.iFrame); - this.lazyLoadScrollContainers.forEach((scrollContainer) => { - scrollContainer.addEventListener('scroll', this.sendFullHeightLazyLoadData); - }); - if (typeof ResizeObserver !== 'undefined') { - const resizeTargets = new Set([ - this.iFrame.parentElement, - ...getEffectiveClippingAncestors(this.iFrame), - ].filter(Boolean) as HTMLElement[]); - this.lazyLoadResizeObserver = new ResizeObserver(this.sendFullHeightLazyLoadData); - resizeTargets.forEach((resizeTarget) => { - this.lazyLoadResizeObserver.observe(resizeTarget); - }); - } - } - } - - private unregisterLazyLoadEvents() { - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - window.removeEventListener('resize', this.sendFullHeightLazyLoadData); - window.removeEventListener('scroll', this.sendFullHeightLazyLoadData, true); - this.lazyLoadResizeObserver?.disconnect(); - this.lazyLoadResizeObserver = undefined; - this.lazyLoadScrollContainers.forEach((scrollContainer) => { - scrollContainer.removeEventListener('scroll', this.sendFullHeightLazyLoadData); - }); - this.lazyLoadScrollContainers = []; - } + this.fullHeightController.onRender(); } /** diff --git a/src/embed/liveboard.spec.ts b/src/embed/liveboard.spec.ts index 754fc510..8da5b9d3 100644 --- a/src/embed/liveboard.spec.ts +++ b/src/embed/liveboard.spec.ts @@ -953,7 +953,7 @@ describe('Liveboard/viz embed tests', () => { const spySetIFrameHeight = jest.spyOn(myObject, 'setIFrameHeight'); myObject.render(); - myObject.setIframeHeightForNonEmbedLiveboard({ + myObject.fullHeightController.handleRouteChange({ data: { currentPath: '/embed/viz/' }, type: 'Route', }); @@ -971,7 +971,7 @@ describe('Liveboard/viz embed tests', () => { const spySetIFrameHeight = jest.spyOn(myObject, 'setIFrameHeight'); myObject.render(); - myObject.setIframeHeightForNonEmbedLiveboard({ + myObject.fullHeightController.handleRouteChange({ data: { currentPath: '/embed/insights/viz/' }, type: 'Route', }); @@ -991,7 +991,7 @@ describe('Liveboard/viz embed tests', () => { .mockImplementation(jest.fn()); myObject.render(); - myObject.setIframeHeightForNonEmbedLiveboard({ + myObject.fullHeightController.handleRouteChange({ data: { currentPath: '/some/other/path/' }, type: 'Route', }); @@ -2370,7 +2370,7 @@ describe('Liveboard/viz embed tests', () => { await liveboardEmbed.render(); // Trigger the lazy load data calculation - (liveboardEmbed as any).sendFullHeightLazyLoadData(); + (liveboardEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 0, @@ -2393,7 +2393,7 @@ describe('Liveboard/viz embed tests', () => { await liveboardEmbed.render(); // Trigger the lazy load data calculation - (liveboardEmbed as any).sendFullHeightLazyLoadData(); + (liveboardEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 0, @@ -2426,7 +2426,7 @@ describe('Liveboard/viz embed tests', () => { await liveboardEmbed.render(); // Trigger the lazy load data calculation - (liveboardEmbed as any).sendFullHeightLazyLoadData(); + (liveboardEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 50, @@ -2538,7 +2538,7 @@ describe('Liveboard/viz embed tests', () => { const mockResponder = jest.fn(); // Trigger the handler directly - (liveboardEmbed as any).requestVisibleEmbedCoordinatesHandler({}, mockResponder); + (liveboardEmbed as any).fullHeightController.handleRequestVisibleCoordinates({}, mockResponder); // Verify the responder was called with the correct data expect(mockResponder).toHaveBeenCalledWith({ @@ -2764,7 +2764,7 @@ describe('Liveboard/viz embed tests', () => { minimumHeight: 800, }); await liveboardEmbed.render(); - expect(liveboardEmbed['defaultHeight']).toBe(800); + expect(liveboardEmbed['fullHeightController'].minimumHeight).toBe(800); }); test('should set default height to 700 when default height is provided', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { @@ -2774,7 +2774,7 @@ describe('Liveboard/viz embed tests', () => { defaultHeight: 700, }); await liveboardEmbed.render(); - expect(liveboardEmbed['defaultHeight']).toBe(700); + expect(liveboardEmbed['fullHeightController'].minimumHeight).toBe(700); }); test('should set default height to 800 when minimum height is provided but default height is not', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { @@ -2784,7 +2784,7 @@ describe('Liveboard/viz embed tests', () => { minimumHeight: 800, }); await liveboardEmbed.render(); - expect(liveboardEmbed['defaultHeight']).toBe(800); + expect(liveboardEmbed['fullHeightController'].minimumHeight).toBe(800); }); test('should set default height to 500 when neither default height nor minimum height is provided', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { @@ -2793,7 +2793,7 @@ describe('Liveboard/viz embed tests', () => { fullHeight: true, }); await liveboardEmbed.render(); - expect(liveboardEmbed['defaultHeight']).toBe(500); + expect(liveboardEmbed['fullHeightController'].minimumHeight).toBe(500); }); }); }); diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index 2c7d1458..39fb3066 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -12,7 +12,6 @@ import { getPreview } from '../utils/graphql/preview-service'; import { ERROR_MESSAGE } from '../errors'; import { EmbedEvent, - MessagePayload, Param, RuntimeFilter, DOMSelector, @@ -20,14 +19,15 @@ import { SearchLiveboardCommonViewConfig as LiveboardOtherViewConfig, BaseViewConfig, LiveboardAppEmbedViewConfig, + FullHeightViewConfig, ErrorDetailsTypes, EmbedErrorCodes, EmbedErrorSeverity, ContextType, DefaultAppInitData, } from '../types'; -import { calculateVisibleElementData, getEffectiveClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; -import { DEFAULT_LAZY_LOADING_MARGIN } from '../config'; +import { FullHeightController } from '../full-height'; +import { getQueryParamString, isUndefined, setParamIfDefined } from '../utils'; import { getAuthPromise } from './base'; import { TsEmbed, V1Embed } from './ts-embed'; import { addPreviewStylesIfNotPresent } from '../utils/global-styles'; @@ -53,71 +53,11 @@ export interface LiveboardEmbedAppInitData extends DefaultAppInitData { * The configuration for the embedded Liveboard or visualization page view. * @group Embed components */ -export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewConfig, LiveboardAppEmbedViewConfig { - /** - * If set to true, the embedded object container dynamically resizes - * according to the height of the Liveboard. - * - * **Note**: Using fullHeight loads all visualizations on the - * Liveboard simultaneously, which results in multiple warehouse - * queries and potentially a longer wait for the topmost - * visualizations to display on the screen. - * Setting `fullHeight` to `false` fetches visualizations - * incrementally as users scroll the page to view the charts and tables. - * - * From SDK 1.52.0, enabling `fullHeight` also turns on - * {@link lazyLoadingForFullHeight} and - * {@link enableScrollableContainerLazyLoading}, so visualizations load as - * they scroll into view. Set either flag to `false` to opt out. - * - * - * Supported embed types: `LiveboardEmbed` - * @version SDK: 1.1.0 | ThoughtSpot: ts7.may.cl, 7.2.1 - * @example - * ```js - * const embed = new LiveboardEmbed('#embed', { - * ... // other liveboard view config - * fullHeight: true, - * }); - * ``` - */ - fullHeight?: boolean; - /** - * This is the minimum height (in pixels) for a full-height Liveboard. - * Setting this height helps resolve issues with empty Liveboards and - * other screens navigable from a Liveboard. - * - * Supported embed types: `LiveboardEmbed` - * @version SDK: 1.5.0 | ThoughtSpot: ts7.oct.cl, 7.2.1 - * @deprecated Use `minimumHeight` instead. - * @default 500 - * @example - * ```js - * const embed = new LiveboardEmbed('#embed', { - * ... // other liveboard view config - * fullHeight: true, - * defaultHeight: 600, - * }); - * ``` - */ - defaultHeight?: number; - /** - * This is the minimum height (in pixels) for a full-height Liveboard. - * Setting this height helps resolve issues with empty Liveboards and - * other screens navigable from a Liveboard. - * - * @version SDK: 1.44.2 | ThoughtSpot: 10.15.0.cl - * @default 500 - * @example - * ```js - * const embed = new LiveboardEmbed('#embed', { - * ... // other liveboard view config - * fullHeight: true, - * minimumHeight: 600, - * }); - * ``` - */ - minimumHeight?: number; +export interface LiveboardViewConfig + extends BaseViewConfig, + FullHeightViewConfig, + LiveboardOtherViewConfig, + LiveboardAppEmbedViewConfig { /** * If set to true, the context menu in visualizations will be enabled. * @version SDK: 1.1.0 | ThoughtSpot: 8.1.0.sw @@ -463,77 +403,6 @@ export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewC * ``` */ isGranularXLSXCSVSchedulesEnabled?: boolean; - /** - * Loads visualizations only as they scroll into the viewport, instead of - * loading the whole full-height Liveboard at once. - * - * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is - * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be - * set explicitly. Set it to `false` to load every visualization upfront. - * The flag has no effect unless `fullHeight` is enabled. - * - * @type {boolean} - * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl - * @default true when `fullHeight` is enabled, from SDK 1.52.0 - * @example - * ```js - * const embed = new LiveboardEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * }) - * ``` - */ - lazyLoadingForFullHeight?: boolean; - /** - * Computes the visible region of the embed against its scrollable and - * clipping ancestors, instead of treating the browser window as the only - * viewport, and tracks scroll and resize on those ancestors. - * - * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is - * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be - * set explicitly. Set it to `false` when the page scrolls with the window - * and the embed has no clipping ancestor, to skip the extra ancestor - * tracking. - * - * @type {boolean} - * @default true when `fullHeight` is enabled, from SDK 1.52.0 - */ - enableScrollableContainerLazyLoading?: boolean; - /** - * How far outside the viewport a visualization starts loading, when - * {@link lazyLoadingForFullHeight} is enabled. - * - * For example, if the margin is set to '10px', - * the visualization will be loaded 10px before its top edge is visible in the - * viewport. - * - * The format is similar to CSS margin, so `'500px 0px'` extends the - * prefetch 500px above and below the viewport and not sideways. Accepted - * units are `px`, `em`, `rem`, `%`, `vh` and `vw`, plus bare `0` and - * `auto`; an invalid value is logged and ignored. - * - * From SDK 1.52.0 this defaults to `'500px 0px'` — roughly one - * visualization ahead of the scroll position, so a chart has usually - * finished loading by the time it scrolls into view. Use a smaller margin - * to cut warehouse queries further, or `'0px'` to load a visualization - * only once it is actually visible. - * - * @type {string} - * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl - * @default '500px 0px' when `fullHeight` is enabled, from SDK 1.52.0 - * @example - * ```js - * const embed = new LiveboardEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * // Using 0px, the visualization will be only loaded when it's visible in the viewport. - * lazyLoadingMargin: '0px', - * }) - * ``` - */ - lazyLoadingMargin?: string; /** * showSpotterLimitations : show limitation text * of the spotter underneath the chat input. @@ -689,37 +558,26 @@ export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewC export class LiveboardEmbed extends V1Embed { protected viewConfig: LiveboardViewConfig; - private defaultHeight = 500; - - private lazyLoadScrollContainers: HTMLElement[] = []; - - private lazyLoadResizeObserver: ResizeObserver | undefined; - + private readonly fullHeightController: FullHeightController; constructor(domSelector: DOMSelector, viewConfig: LiveboardViewConfig) { viewConfig.embedComponentType = 'LiveboardEmbed'; super(domSelector, viewConfig); - if (this.viewConfig.fullHeight === true) { - if (this.viewConfig.vizId) { - logger.warn('Full height is currently only supported for Liveboard embeds.' + - 'Using full height with vizId might lead to unexpected behavior.'); - } - - if (this.viewConfig.lazyLoadingForFullHeight === undefined) { - this.viewConfig.lazyLoadingForFullHeight = true; - } - if (this.viewConfig.enableScrollableContainerLazyLoading === undefined) { - this.viewConfig.enableScrollableContainerLazyLoading = true; - } - if (this.viewConfig.lazyLoadingMargin === undefined) { - this.viewConfig.lazyLoadingMargin = DEFAULT_LAZY_LOADING_MARGIN; - } - - this.on(EmbedEvent.RouteChange, this.setIframeHeightForNonEmbedLiveboard); - this.on(EmbedEvent.EmbedHeight, this.updateIFrameHeight); - this.on(EmbedEvent.EmbedIframeCenter, this.embedIframeCenter); - this.on(EmbedEvent.RequestVisibleEmbedCoordinates, this.requestVisibleEmbedCoordinatesHandler); + this.fullHeightController = new FullHeightController(this.viewConfig, { + getIframe: () => this.iFrame, + setFrameHeight: (height) => this.setIFrameHeight(height), + on: (eventType, callback) => { + this.on(eventType, callback); + }, + trigger: (hostEvent, data) => { + this.trigger(hostEvent, data); + }, + }); + if (this.viewConfig.fullHeight === true && this.viewConfig.vizId) { + logger.warn('Full height is currently only supported for Liveboard embeds.' + + 'Using full height with vizId might lead to unexpected behavior.'); } + this.fullHeightController.registerEventHandlers(); } protected async getAppInitData(): Promise { @@ -743,9 +601,6 @@ export class LiveboardEmbed extends V1Embed { params = this.getBaseQueryParams(params); const { enableVizTransformations, - fullHeight, - defaultHeight, - minimumHeight, visibleVizs, liveboardV2, vizId, @@ -794,16 +649,7 @@ export class LiveboardEmbed extends V1Embed { const preventLiveboardFilterRemoval = this.viewConfig.preventLiveboardFilterRemoval || this.viewConfig.preventPinboardFilterRemoval; - if (fullHeight === true) { - params[Param.fullHeight] = true; - if (this.viewConfig.lazyLoadingForFullHeight) { - params[Param.IsLazyLoadingForEmbedEnabled] = true; - if (isValidCssMargin(this.viewConfig.lazyLoadingMargin)) { - params[Param.RootMarginForLazyLoad] = this.viewConfig.lazyLoadingMargin; - } - } - } - this.defaultHeight = minimumHeight || defaultHeight || this.defaultHeight; + this.fullHeightController.addQueryParams(params); if (enableVizTransformations !== undefined) { params[Param.EnableVizTransformations] = enableVizTransformations.toString(); } @@ -1018,32 +864,6 @@ export class LiveboardEmbed extends V1Embed { return suffix; } - private sendFullHeightLazyLoadData = () => { - const data = calculateVisibleElementData( - this.iFrame, - this.viewConfig.enableScrollableContainerLazyLoading, - ); - // this should be fired only if the lazyLoadingForFullHeight and fullHeight are true - if(this.viewConfig.lazyLoadingForFullHeight && this.viewConfig.fullHeight){ - this.trigger(HostEvent.VisibleEmbedCoordinates, data); - } - }; - - /** - * This is a handler for the RequestVisibleEmbedCoordinates event. - * It is used to send the visible coordinates data to the host application. - * @param data The event payload - * @param responder The responder function - */ - private requestVisibleEmbedCoordinatesHandler = (data: MessagePayload, responder: any) => { - logger.info('Sending RequestVisibleEmbedCoordinates', data); - const visibleCoordinatesData = calculateVisibleElementData( - this.iFrame, - this.viewConfig.enableScrollableContainerLazyLoading, - ); - responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); - } - /** * Construct the URL of the embedded ThoughtSpot Liveboard or visualization * to be loaded within the iFrame. @@ -1069,44 +889,6 @@ export class LiveboardEmbed extends V1Embed { )}`; } - /** - * Set the iframe height as per the computed height received - * from the ThoughtSpot app. - * @param data The event payload - */ - private updateIFrameHeight = (data: MessagePayload) => { - this.setIFrameHeight(Math.max(data.data, this.defaultHeight)); - this.sendFullHeightLazyLoadData(); - }; - - private embedIframeCenter = (data: MessagePayload, responder: any) => { - const obj = this.getIframeCenter(); - responder({ type: EmbedEvent.EmbedIframeCenter, data: obj }); - }; - - private setIframeHeightForNonEmbedLiveboard = (data: MessagePayload) => { - const { height: frameHeight } = this.viewConfig.frameParams || {}; - - const liveboardRelatedRoutes = [ - '/pinboard/', - '/insights/pinboard/', - '/schedules/', - '/embed/viz/', - '/embed/insights/viz/', - '/liveboard/', - '/insights/liveboard/', - '/tsl-editor/PINBOARD_ANSWER_BOOK/', - '/import-tsl/PINBOARD_ANSWER_BOOK/', - ]; - - if (liveboardRelatedRoutes.some((path) => data.data.currentPath.startsWith(path))) { - // Ignore the height reset of the frame, if the navigation is - // only within the liveboard page. - return; - } - this.setIFrameHeight(frameHeight || this.defaultHeight); - }; - private setActiveTab(data: { tabId: string }) { if (!this.viewConfig.vizId) { const prefixPath = this.iFrame.src.split('#/')[1].split('/tab')[0]; @@ -1223,53 +1005,11 @@ export class LiveboardEmbed extends V1Embed { */ public destroy() { super.destroy(); - this.unregisterLazyLoadEvents(); + this.fullHeightController.destroy(); } private postRender() { - this.registerLazyLoadEvents(); - } - - private registerLazyLoadEvents() { - if(!this.iFrame) { - return; - } - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - this.unregisterLazyLoadEvents(); - // TODO: Use passive: true, install modernizr to check for passive - window.addEventListener('resize', this.sendFullHeightLazyLoadData); - window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); - if (!this.viewConfig.enableScrollableContainerLazyLoading) { - return; - } - this.lazyLoadScrollContainers = getScrollableAncestors(this.iFrame); - this.lazyLoadScrollContainers.forEach((scrollContainer) => { - scrollContainer.addEventListener('scroll', this.sendFullHeightLazyLoadData); - }); - if (typeof ResizeObserver !== 'undefined') { - const resizeTargets = new Set([ - this.iFrame.parentElement, - ...getEffectiveClippingAncestors(this.iFrame), - ].filter(Boolean) as HTMLElement[]); - this.lazyLoadResizeObserver = new ResizeObserver(this.sendFullHeightLazyLoadData); - resizeTargets.forEach((resizeTarget) => { - this.lazyLoadResizeObserver.observe(resizeTarget); - }); - } - } - } - - private unregisterLazyLoadEvents() { - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - window.removeEventListener('resize', this.sendFullHeightLazyLoadData); - window.removeEventListener('scroll', this.sendFullHeightLazyLoadData, true); - this.lazyLoadResizeObserver?.disconnect(); - this.lazyLoadResizeObserver = undefined; - this.lazyLoadScrollContainers.forEach((scrollContainer) => { - scrollContainer.removeEventListener('scroll', this.sendFullHeightLazyLoadData); - }); - this.lazyLoadScrollContainers = []; - } + this.fullHeightController.onRender(); } /** diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 5c14513e..1dd193db 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -23,7 +23,7 @@ import { AnswerService } from '../utils/graphql/answerService/answerService'; import { getEncodedQueryParamsString, getCssDimension, - getOffsetTop, + calculateElementCenter, embedEventStatus, setAttributes, getCustomisations, @@ -1479,30 +1479,7 @@ export class TsEmbed { * View port height. */ protected getIframeCenter() { - const offsetTopClient = getOffsetTop(this.iFrame); - const scrollTopClient = window.scrollY; - const viewPortHeight = window.innerHeight; - const iframeHeight = this.iFrame.offsetHeight; - const iframeScrolled = scrollTopClient - offsetTopClient; - let iframeVisibleViewPort; - let iframeOffset; - - if (iframeScrolled < 0) { - iframeVisibleViewPort = viewPortHeight - (offsetTopClient - scrollTopClient); - iframeVisibleViewPort = Math.min(iframeHeight, iframeVisibleViewPort); - iframeOffset = 0; - } else { - iframeVisibleViewPort = Math.min(iframeHeight - iframeScrolled, viewPortHeight); - iframeOffset = iframeScrolled; - } - const iframeCenter = iframeOffset + iframeVisibleViewPort / 2; - return { - iframeCenter, - iframeScrolled, - iframeHeight, - viewPortHeight, - iframeVisibleViewPort, - }; + return calculateElementCenter(this.iFrame); } /** diff --git a/src/full-height.spec.ts b/src/full-height.spec.ts new file mode 100644 index 00000000..8b7c8153 --- /dev/null +++ b/src/full-height.spec.ts @@ -0,0 +1,274 @@ +import { FullHeightController, FullHeightEmbedHost } from './full-height'; +import { + EmbedEvent, FullHeightViewConfig, HostEvent, MessageCallback, Param, +} from './types'; +import { logger } from './utils/logger'; +import { DEFAULT_LAZY_LOADING_MARGIN } from './config'; + +describe('FullHeightController', () => { + let iFrame: HTMLIFrameElement; + let host: FullHeightEmbedHost; + let handlers: Map; + + const createControllerFor = (viewConfig: FullHeightViewConfig) => createController(viewConfig); + + const createController = (viewConfig: FullHeightViewConfig) => { + handlers = new Map(); + iFrame = document.createElement('iframe'); + document.body.appendChild(iFrame); + host = { + getIframe: () => iFrame, + setFrameHeight: jest.fn(), + on: (eventType, callback) => { + handlers.set(eventType, callback); + }, + trigger: jest.fn(), + }; + const controller = new FullHeightController(viewConfig, host); + controller.registerEventHandlers(); + return controller; + }; + + afterEach(() => { + document.body.innerHTML = ''; + jest.restoreAllMocks(); + }); + + describe('registerEventHandlers', () => { + it('registers every full-height handler when fullHeight is enabled', () => { + createController({ fullHeight: true }); + expect([...handlers.keys()]).toEqual([ + EmbedEvent.RouteChange, + EmbedEvent.EmbedHeight, + EmbedEvent.EmbedIframeCenter, + EmbedEvent.RequestVisibleEmbedCoordinates, + ]); + }); + + it('registers nothing when fullHeight is not enabled', () => { + createController({}); + expect(handlers.size).toBe(0); + }); + }); + + describe('lazy loading defaults', () => { + it('turns lazy loading on for a full-height embed', () => { + const viewConfig: FullHeightViewConfig = { fullHeight: true }; + createControllerFor(viewConfig); + expect(viewConfig.lazyLoadingForFullHeight).toBe(true); + expect(viewConfig.enableScrollableContainerLazyLoading).toBe(true); + expect(viewConfig.lazyLoadingMargin).toBe(DEFAULT_LAZY_LOADING_MARGIN); + }); + + it('leaves an explicit opt-out alone', () => { + const viewConfig: FullHeightViewConfig = { + fullHeight: true, + lazyLoadingForFullHeight: false, + enableScrollableContainerLazyLoading: false, + lazyLoadingMargin: '0px', + }; + createControllerFor(viewConfig); + expect(viewConfig.lazyLoadingForFullHeight).toBe(false); + expect(viewConfig.enableScrollableContainerLazyLoading).toBe(false); + expect(viewConfig.lazyLoadingMargin).toBe('0px'); + }); + + it('defaults nothing when fullHeight is not enabled', () => { + const viewConfig: FullHeightViewConfig = {}; + createControllerFor(viewConfig); + expect(viewConfig.lazyLoadingForFullHeight).toBeUndefined(); + expect(viewConfig.enableScrollableContainerLazyLoading).toBeUndefined(); + expect(viewConfig.lazyLoadingMargin).toBeUndefined(); + }); + }); + + describe('minimumHeight', () => { + it('falls back to 500 when neither height is configured', () => { + expect(createController({ fullHeight: true }).minimumHeight).toBe(500); + }); + + it('prefers minimumHeight over the deprecated defaultHeight', () => { + const controller = createController({ + fullHeight: true, + defaultHeight: 700, + minimumHeight: 800, + }); + expect(controller.minimumHeight).toBe(800); + }); + + it('honours the deprecated defaultHeight when minimumHeight is absent', () => { + const controller = createController({ fullHeight: true, defaultHeight: 700 }); + expect(controller.minimumHeight).toBe(700); + }); + }); + + describe('addQueryParams', () => { + it('adds no params when fullHeight is not enabled', () => { + const params: any = {}; + createController({ lazyLoadingForFullHeight: true }).addQueryParams(params); + expect(params).toEqual({}); + }); + + it('adds only the full height param when lazy loading is off', () => { + const params: any = {}; + createController({ + fullHeight: true, + lazyLoadingForFullHeight: false, + }).addQueryParams(params); + expect(params).toEqual({ [Param.fullHeight]: true }); + }); + + it('adds the lazy loading params, including a valid margin', () => { + const params: any = {}; + createController({ + fullHeight: true, + lazyLoadingForFullHeight: true, + lazyLoadingMargin: '100px 0px', + }).addQueryParams(params); + expect(params).toEqual({ + [Param.fullHeight]: true, + [Param.IsLazyLoadingForEmbedEnabled]: true, + [Param.RootMarginForLazyLoad]: '100px 0px', + }); + }); + + it('drops an invalid lazy loading margin', () => { + // An invalid margin is reported to the developer, not sent on. + const loggerError = jest.spyOn(logger, 'error').mockImplementation(jest.fn()); + const params: any = {}; + createController({ + fullHeight: true, + lazyLoadingForFullHeight: true, + lazyLoadingMargin: 'not-a-margin', + }).addQueryParams(params); + expect(params[Param.RootMarginForLazyLoad]).toBeUndefined(); + expect(loggerError).toHaveBeenCalled(); + }); + }); + + describe('EmbedHeight', () => { + it('never sizes the frame below the configured minimum', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.EmbedHeight)({ data: 300 } as any); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + + it('uses the height reported by the app when it clears the minimum', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.EmbedHeight)({ data: 1200 } as any); + expect(host.setFrameHeight).toHaveBeenCalledWith(1200); + }); + + it('pushes the visible coordinates only when lazy loading is on', () => { + createController({ fullHeight: true, lazyLoadingForFullHeight: false }); + handlers.get(EmbedEvent.EmbedHeight)({ data: 1200 } as any); + expect(host.trigger).not.toHaveBeenCalled(); + + createController({ fullHeight: true, lazyLoadingForFullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)({ data: 1200 } as any); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + }); + }); + + describe('RouteChange', () => { + const routeChange = (currentPath: string) => ({ data: { currentPath } } as any); + + it('leaves the height alone while navigating within a Liveboard', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/embed/viz/abc')); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('resets to frameParams.height when leaving the Liveboard routes', () => { + createController({ fullHeight: true, frameParams: { height: 640 } }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/some/other/path/')); + expect(host.setFrameHeight).toHaveBeenCalledWith(640); + }); + + it('resets to the minimum height when frameParams has no height', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/some/other/path/')); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + }); + + describe('coordinate requests', () => { + it('responds to RequestVisibleEmbedCoordinates with the visible region', () => { + createController({ fullHeight: true }); + const responder = jest.fn(); + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any, responder); + expect(responder).toHaveBeenCalledWith({ + type: EmbedEvent.RequestVisibleEmbedCoordinates, + data: expect.objectContaining({ top: expect.any(Number) }), + }); + }); + + it('responds to EmbedIframeCenter with the center of the visible region', () => { + createController({ fullHeight: true }); + const responder = jest.fn(); + handlers.get(EmbedEvent.EmbedIframeCenter)({} as any, responder); + expect(responder).toHaveBeenCalledWith({ + type: EmbedEvent.EmbedIframeCenter, + data: expect.objectContaining({ iframeCenter: expect.any(Number) }), + }); + }); + }); + + describe('lazy load listeners', () => { + it('attaches window listeners on render and removes them on destroy', () => { + const add = jest.spyOn(window, 'addEventListener'); + const remove = jest.spyOn(window, 'removeEventListener'); + const controller = createController({ + fullHeight: true, + lazyLoadingForFullHeight: true, + }); + + controller.onRender(); + expect(add).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(add).toHaveBeenCalledWith('scroll', expect.any(Function), true); + + controller.destroy(); + expect(remove).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(remove).toHaveBeenCalledWith('scroll', expect.any(Function), true); + }); + + it('attaches nothing when lazy loading is off', () => { + const add = jest.spyOn(window, 'addEventListener'); + createController({ + fullHeight: true, + lazyLoadingForFullHeight: false, + }).onRender(); + expect(add).not.toHaveBeenCalled(); + }); + + it('observes the scrollable ancestors when container lazy loading is on', () => { + const observe = jest.fn(); + const disconnect = jest.fn(); + (window as any).ResizeObserver = jest.fn(() => ({ observe, disconnect })); + + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + const addContainerListener = jest.spyOn(scrollContainer, 'addEventListener'); + const removeContainerListener = jest.spyOn(scrollContainer, 'removeEventListener'); + + const controller = createController({ + fullHeight: true, + lazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: true, + }); + scrollContainer.appendChild(iFrame); + document.body.appendChild(scrollContainer); + + controller.onRender(); + expect(addContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(observe).toHaveBeenCalled(); + + controller.destroy(); + expect(removeContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(disconnect).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/full-height.ts b/src/full-height.ts new file mode 100644 index 00000000..d94d3b86 --- /dev/null +++ b/src/full-height.ts @@ -0,0 +1,307 @@ +/** + * Copyright (c) 2025 + * + * Full-height support for the Liveboard and app embeds. + * @summary Full height + */ + +import { + EmbedEvent, + FullHeightViewConfig, + HostEvent, + MessageCallback, + MessagePayload, + Param, + QueryParams, +} from './types'; +import { + calculateElementCenter, + calculateVisibleElementData, + getEffectiveClippingAncestors, + getScrollableAncestors, + isValidCssMargin, +} from './utils'; +import { logger } from './utils/logger'; +import { DEFAULT_LAZY_LOADING_MARGIN } from './config'; + +/** + * The height the embed falls back to when the host app has not configured + * `minimumHeight` or `defaultHeight`. + */ +const DEFAULT_MINIMUM_HEIGHT = 500; + +/** + * Routes that are part of the Liveboard experience itself. Navigating between + * these does not reset the frame height, because the ThoughtSpot app keeps + * reporting a height of its own for them. + */ +const LIVEBOARD_RELATED_ROUTES = [ + '/pinboard/', + '/insights/pinboard/', + '/schedules/', + '/embed/viz/', + '/embed/insights/viz/', + '/liveboard/', + '/insights/liveboard/', + '/tsl-editor/PINBOARD_ANSWER_BOOK/', + '/import-tsl/PINBOARD_ANSWER_BOOK/', +]; + +/** + * The subset of the embed the full-height controller drives. Keeping this + * narrow lets the controller stay independent of the embed class hierarchy. + */ +export interface FullHeightEmbedHost { + /** + * Returns the embedded iframe. The iframe only exists once the embed has + * rendered, so this is a callback rather than a value. + */ + getIframe: () => HTMLIFrameElement; + /** + * Sets the height of the embed container. + */ + setFrameHeight: (height: number | string) => void; + /** + * Registers an SDK-owned handler for an embed event. + */ + on: (eventType: EmbedEvent, callback: MessageCallback) => void; + /** + * Sends a host event to the embedded ThoughtSpot app. + */ + trigger: (hostEvent: HostEvent, data: unknown) => void; +} + +/** + * Owns every piece of full-height behaviour for an embed: the height + * negotiation with the ThoughtSpot app, the query parameters that switch the + * feature on, and the viewport listeners that drive lazy loading. + * + * The controller is inert unless `fullHeight` is enabled, so embeds can create + * one unconditionally. + */ +export class FullHeightController { + private scrollContainers: HTMLElement[] = []; + + private resizeObserver: ResizeObserver | undefined; + + constructor( + private readonly viewConfig: FullHeightViewConfig, + private readonly host: FullHeightEmbedHost, + ) { + this.applyLazyLoadingDefaults(); + } + + /** + * Turns lazy loading on for a full-height embed unless the host app has + * opted out. Mutates the view config in place so that everything reading it + * later — query params, listeners, visibility maths — sees the same values. + */ + private applyLazyLoadingDefaults(): void { + if (!this.isEnabled) { + return; + } + if (this.viewConfig.lazyLoadingForFullHeight === undefined) { + this.viewConfig.lazyLoadingForFullHeight = true; + } + if (this.viewConfig.enableScrollableContainerLazyLoading === undefined) { + this.viewConfig.enableScrollableContainerLazyLoading = true; + } + if (this.viewConfig.lazyLoadingMargin === undefined) { + this.viewConfig.lazyLoadingMargin = DEFAULT_LAZY_LOADING_MARGIN; + } + } + + /** + * Whether the host app asked for a full-height embed. + */ + private get isEnabled(): boolean { + return this.viewConfig.fullHeight === true; + } + + /** + * Whether visualizations should load as they scroll into view, which + * requires the SDK to report the visible region of the embed. + */ + private get isLazyLoadEnabled(): boolean { + return this.isEnabled && !!this.viewConfig.lazyLoadingForFullHeight; + } + + /** + * The floor for the frame height. `defaultHeight` is the deprecated + * spelling of `minimumHeight` and is still honoured for compatibility. + */ + public get minimumHeight(): number { + const { minimumHeight, defaultHeight } = this.viewConfig; + return minimumHeight || defaultHeight || DEFAULT_MINIMUM_HEIGHT; + } + + /** + * Registers the embed event handlers the feature depends on. Call this from + * the embed constructor, before `render`. + */ + public registerEventHandlers(): void { + if (!this.isEnabled) { + return; + } + this.host.on(EmbedEvent.RouteChange, this.handleRouteChange); + this.host.on(EmbedEvent.EmbedHeight, this.handleEmbedHeight); + this.host.on(EmbedEvent.EmbedIframeCenter, this.handleEmbedIframeCenter); + this.host.on( + EmbedEvent.RequestVisibleEmbedCoordinates, + this.handleRequestVisibleCoordinates, + ); + } + + /** + * Adds the full-height query parameters to the embed URL params. + * @param params The query parameters being built by the embed + */ + public addQueryParams(params: QueryParams): void { + if (!this.isEnabled) { + return; + } + params[Param.fullHeight] = true; + if (!this.viewConfig.lazyLoadingForFullHeight) { + return; + } + params[Param.IsLazyLoadingForEmbedEnabled] = true; + if (isValidCssMargin(this.viewConfig.lazyLoadingMargin)) { + params[Param.RootMarginForLazyLoad] = this.viewConfig.lazyLoadingMargin; + } + } + + /** + * Attaches the viewport listeners that keep lazy loading in sync. Call this + * once the embed has rendered and the iframe exists. + */ + public onRender(): void { + this.registerLazyLoadListeners(); + } + + /** + * Detaches every listener and observer owned by the controller. + */ + public destroy(): void { + this.unregisterLazyLoadListeners(); + } + + /** + * Sets the frame height to the height reported by the ThoughtSpot app, + * never going below the configured minimum. + */ + private handleEmbedHeight = (payload: MessagePayload): void => { + this.host.setFrameHeight(Math.max(payload.data, this.minimumHeight)); + this.sendVisibleCoordinates(); + }; + + /** + * Answers the app's request for the center of the visible embed region. + */ + private handleEmbedIframeCenter = ( + payload: MessagePayload, + responder?: (data: any) => void, + ): void => { + responder?.({ + type: EmbedEvent.EmbedIframeCenter, + data: calculateElementCenter(this.host.getIframe()), + }); + }; + + /** + * Answers the app's request for the currently visible embed coordinates. + */ + private handleRequestVisibleCoordinates = ( + payload: MessagePayload, + responder?: (data: any) => void, + ): void => { + logger.info('Sending RequestVisibleEmbedCoordinates', payload); + responder?.({ + type: EmbedEvent.RequestVisibleEmbedCoordinates, + data: this.getVisibleCoordinates(), + }); + }; + + /** + * Resets the frame height when the app navigates away from a Liveboard, + * since only Liveboard routes report a height of their own. + */ + private handleRouteChange = (payload: MessagePayload): void => { + const currentPath: string = payload.data.currentPath; + if (LIVEBOARD_RELATED_ROUTES.some((route) => currentPath.startsWith(route))) { + return; + } + this.host.setFrameHeight(this.viewConfig.frameParams?.height || this.minimumHeight); + }; + + /** + * Pushes the visible embed region to the app so it can decide which + * visualizations to load. + */ + private sendVisibleCoordinates = (): void => { + if (!this.isLazyLoadEnabled) { + return; + } + this.host.trigger(HostEvent.VisibleEmbedCoordinates, this.getVisibleCoordinates()); + }; + + private getVisibleCoordinates() { + return calculateVisibleElementData( + this.host.getIframe(), + this.viewConfig.enableScrollableContainerLazyLoading, + ); + } + + private registerLazyLoadListeners(): void { + if (!this.isLazyLoadEnabled || !this.host.getIframe()) { + return; + } + // Re-registering is safe: drop whatever a previous render attached. + this.unregisterLazyLoadListeners(); + // TODO: Use passive: true, install modernizr to check for passive + window.addEventListener('resize', this.sendVisibleCoordinates); + window.addEventListener('scroll', this.sendVisibleCoordinates, true); + if (!this.viewConfig.enableScrollableContainerLazyLoading) { + return; + } + this.observeScrollableContainers(); + } + + /** + * Tracks the ancestors that can scroll or clip the embed, so the visible + * region stays correct when the embed lives inside its own scroll + * container rather than the page. + */ + private observeScrollableContainers(): void { + const iFrame = this.host.getIframe(); + this.scrollContainers = getScrollableAncestors(iFrame); + this.scrollContainers.forEach((scrollContainer) => { + scrollContainer.addEventListener('scroll', this.sendVisibleCoordinates); + }); + if (typeof ResizeObserver === 'undefined') { + return; + } + const resizeTargets = new Set( + [iFrame.parentElement, ...getEffectiveClippingAncestors(iFrame)].filter( + Boolean, + ) as HTMLElement[], + ); + this.resizeObserver = new ResizeObserver(this.sendVisibleCoordinates); + resizeTargets.forEach((resizeTarget) => { + this.resizeObserver.observe(resizeTarget); + }); + } + + private unregisterLazyLoadListeners(): void { + if (!this.isLazyLoadEnabled) { + return; + } + window.removeEventListener('resize', this.sendVisibleCoordinates); + window.removeEventListener('scroll', this.sendVisibleCoordinates, true); + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + this.scrollContainers.forEach((scrollContainer) => { + scrollContainer.removeEventListener('scroll', this.sendVisibleCoordinates); + }); + this.scrollContainers = []; + } +} diff --git a/src/types.ts b/src/types.ts index f064084b..34226395 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10030,3 +10030,164 @@ export interface VisualizationOverrides { /** Table visualization overrides */ table?: TableOverrides; } + +/** + * The configuration object for the full-height behaviour shared by the + * Liveboard and app embeds. + * + * When `fullHeight` is enabled the SDK resizes the embed container to match the + * height reported by the ThoughtSpot app, and — when lazy loading is also + * enabled — keeps the app informed of the portion of the embed that is + * currently visible so that visualizations load on demand. + */ +export interface FullHeightViewConfig extends BaseViewConfig { + /** + * If set to true, the embedded object container dynamically resizes + * according to the height of the Liveboard. + * + * **Note**: Using fullHeight loads all visualizations on the + * Liveboard simultaneously, which results in multiple warehouse + * queries and potentially a longer wait for the topmost + * visualizations to display on the screen. + * Setting `fullHeight` to `false` fetches visualizations + * incrementally as users scroll the page to view the charts and tables. + * + * From SDK 1.52.0, enabling `fullHeight` also turns on + * {@link lazyLoadingForFullHeight} and + * {@link enableScrollableContainerLazyLoading}, so visualizations load as + * they scroll into view. Set either flag to `false` to opt out. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @version SDK: 1.1.0 | ThoughtSpot: ts7.may.cl, 7.2.1 + * @example + * ```js + * // Replace with the embed component name. + * // For example, AppEmbed or LiveboardEmbed + * const embed = new ('#embed', { + * ... // other view config + * fullHeight: true, + * }); + * ``` + */ + fullHeight?: boolean; + /** + * This is the minimum height (in pixels) for a full-height Liveboard. + * Setting this height helps resolve issues with empty Liveboards and + * other screens navigable from a Liveboard. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @version SDK: 1.44.2 | ThoughtSpot: 10.15.0.cl + * @default 500 + * @example + * ```js + * // Replace with the embed component name. + * // For example, AppEmbed or LiveboardEmbed + * const embed = new ('#embed', { + * ... // other view config + * fullHeight: true, + * minimumHeight: 600, + * }); + * ``` + */ + minimumHeight?: number; + /** + * This is the minimum height (in pixels) for a full-height Liveboard. + * Setting this height helps resolve issues with empty Liveboards and + * other screens navigable from a Liveboard. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @version SDK: 1.5.0 | ThoughtSpot: ts7.oct.cl, 7.2.1 + * @deprecated Use `minimumHeight` instead. + * @default 500 + * @example + * ```js + * // Replace with the embed component name. + * // For example, AppEmbed or LiveboardEmbed + * const embed = new ('#embed', { + * ... // other view config + * fullHeight: true, + * defaultHeight: 600, + * }); + * ``` + */ + defaultHeight?: number; + /** + * Loads visualizations only as they scroll into the viewport, instead of + * loading the whole full-height Liveboard at once. + * + * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is + * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be + * set explicitly. Set it to `false` to load every visualization upfront. + * The flag has no effect unless `fullHeight` is enabled. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @type {boolean} + * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl + * @default true when `fullHeight` is enabled, from SDK 1.52.0 + * @example + * ```js + * // Replace with the embed component name. + * // For example, AppEmbed or LiveboardEmbed + * const embed = new ('#embed-container', { + * // ...other options + * fullHeight: true, + * lazyLoadingForFullHeight: true, + * }) + * ``` + */ + lazyLoadingForFullHeight?: boolean; + /** + * How far outside the viewport a visualization starts loading, when + * {@link lazyLoadingForFullHeight} is enabled. + * + * For example, if the margin is set to '10px', + * the visualization will be loaded 10px before its top edge is visible in the + * viewport. + * + * The format is similar to CSS margin, so `'500px 0px'` extends the + * prefetch 500px above and below the viewport and not sideways. Accepted + * units are `px`, `em`, `rem`, `%`, `vh` and `vw`, plus bare `0` and + * `auto`; an invalid value is logged and ignored. + * + * From SDK 1.52.0 this defaults to `'500px 0px'` — roughly one + * visualization ahead of the scroll position, so a chart has usually + * finished loading by the time it scrolls into view. Use a smaller margin + * to cut warehouse queries further, or `'0px'` to load a visualization + * only once it is actually visible. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @type {string} + * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl + * @default '500px 0px' when `fullHeight` is enabled, from SDK 1.52.0 + * @example + * ```js + * // Replace with the embed component name. + * // For example, AppEmbed or LiveboardEmbed + * const embed = new ('#embed-container', { + * // ...other options + * fullHeight: true, + * lazyLoadingForFullHeight: true, + * // With 0px, the visualization only starts loading once it is + * // visible in the viewport. + * lazyLoadingMargin: '0px', + * }) + * ``` + */ + lazyLoadingMargin?: string; + /** + * Computes the visible region of the embed against its scrollable and + * clipping ancestors, instead of treating the browser window as the only + * viewport, and tracks scroll and resize on those ancestors. + * + * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is + * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be + * set explicitly. Set it to `false` when the page scrolls with the window + * and the embed has no clipping ancestor, to skip the extra ancestor + * tracking. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @type {boolean} + * @default true when `fullHeight` is enabled, from SDK 1.52.0 + */ + enableScrollableContainerLazyLoading?: boolean; +} diff --git a/src/utils.ts b/src/utils.ts index bcdb6a06..e4678de4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -776,3 +776,41 @@ export const setParamIfDefined = ( queryParams[param] = asBoolean ? !!value : value; } }; + +/** + * Calculates the element center for the currently visible viewport of the + * element, using the scroll position of the host app, the offsetTop of the + * element in the host app, and the viewport height of the tab. + * + * The returned keys are sent to the ThoughtSpot app over postMessage, so they + * are named after the iframe and must not be renamed. + * @param element The element to measure + * @returns The element center within the visible viewport, the element height, + * and the viewport height. + */ +export const calculateElementCenter = (element: HTMLElement) => { + const offsetTopClient = getOffsetTop(element); + const scrollTopClient = window.scrollY; + const viewPortHeight = window.innerHeight; + const iframeHeight = element.offsetHeight; + const iframeScrolled = scrollTopClient - offsetTopClient; + let iframeVisibleViewPort; + let iframeOffset; + + if (iframeScrolled < 0) { + iframeVisibleViewPort = viewPortHeight - (offsetTopClient - scrollTopClient); + iframeVisibleViewPort = Math.min(iframeHeight, iframeVisibleViewPort); + iframeOffset = 0; + } else { + iframeVisibleViewPort = Math.min(iframeHeight - iframeScrolled, viewPortHeight); + iframeOffset = iframeScrolled; + } + const iframeCenter = iframeOffset + iframeVisibleViewPort / 2; + return { + iframeCenter, + iframeScrolled, + iframeHeight, + viewPortHeight, + iframeVisibleViewPort, + }; +}; From 545a48c1feba474f04d0bdfbf4b11c6e82cb7be1 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Fri, 28 Aug 2026 11:10:17 +0530 Subject: [PATCH 2/8] SCAL-334772 resolved comment --- src/embed/app.spec.ts | 19 ---------- src/embed/app.ts | 45 +++++++++--------------- src/embed/liveboard.ts | 38 ++++++++++---------- src/embed/ts-embed.spec.ts | 10 ------ src/embed/ts-embed.ts | 13 ------- src/full-height.spec.ts | 14 ++++---- src/full-height.ts | 3 +- src/types.ts | 2 +- src/utils.spec.ts | 71 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 119 insertions(+), 96 deletions(-) diff --git a/src/embed/app.spec.ts b/src/embed/app.spec.ts index 0120cc57..ae550de0 100644 --- a/src/embed/app.spec.ts +++ b/src/embed/app.spec.ts @@ -2144,7 +2144,6 @@ describe('App embed tests', () => { const onSpy = jest.spyOn(AppEmbed.prototype, 'on').mockImplementation((event, callback) => { return null; }); - jest.spyOn(TsEmbed.prototype as any, 'getIframeCenter').mockReturnValue({}); jest.spyOn(TsEmbed.prototype as any, 'setIFrameHeight').mockReturnValue({}); // Create the AppEmbed instance @@ -2821,24 +2820,6 @@ describe('AppEmbed uncovered branch tests', () => { }); }); - test('protected updateIFrameHeight still floors at the configured minimum', async () => { - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - minimumHeight: 800, - } as AppViewConfig); - await appEmbed.render(); - const setHeight = jest - .spyOn(appEmbed as any, 'setIFrameHeight') - .mockImplementation(jest.fn()); - - (appEmbed as any).updateIFrameHeight({ data: 300 }); - expect(setHeight).toHaveBeenCalledWith(800); - - (appEmbed as any).updateIFrameHeight({ data: 1200 }); - expect(setHeight).toHaveBeenCalledWith(1200); - }); - test('lazy load registration should return early when iFrame is not set', () => { const appEmbed = new AppEmbed(getRootEl(), { ...defaultViewConfig, diff --git a/src/embed/app.ts b/src/embed/app.ts index 10125fe9..4feb603e 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -16,7 +16,6 @@ import { HostEvent, EmbedEvent, AllEmbedViewConfig, - MessagePayload, FullHeightViewConfig, DefaultAppInitData, VisualizationOverrides, @@ -928,22 +927,24 @@ export interface AppEmbedAppInitData extends DefaultAppInitData { export class AppEmbed extends V1Embed { protected viewConfig: AppViewConfig; - private readonly fullHeightController: FullHeightController; + private readonly fullHeightController?: FullHeightController; constructor(domSelector: DOMSelector, viewConfig: AppViewConfig) { viewConfig.embedComponentType = 'AppEmbed'; super(domSelector, viewConfig); - this.fullHeightController = new FullHeightController(this.viewConfig, { - getIframe: () => this.iFrame, - setFrameHeight: (height) => this.setIFrameHeight(height), - on: (eventType, callback) => { - this.on(eventType, callback); - }, - trigger: (hostEvent, data) => { - this.trigger(hostEvent, data); - }, - }); - this.fullHeightController.registerEventHandlers(); + if (this.viewConfig.fullHeight === true) { + this.fullHeightController = new FullHeightController(this.viewConfig, { + getIframe: () => this.iFrame, + setFrameHeight: (height) => this.setIFrameHeight(height), + on: (eventType, callback) => { + this.on(eventType, callback); + }, + trigger: (hostEvent, data) => { + this.trigger(hostEvent, data); + }, + }); + this.fullHeightController.registerEventHandlers(); + } } /** @@ -1140,7 +1141,7 @@ export class AppEmbed extends V1Embed { params[Param.HideNotification] = !!hideNotification; } - this.fullHeightController.addQueryParams(params); + this.fullHeightController?.addQueryParams(params); if (tag) { params[Param.Tag] = tag; @@ -1303,18 +1304,6 @@ export class AppEmbed extends V1Embed { return url; } - /** - * Set the iframe height as per the computed height received - * from the ThoughtSpot app. - * - * Retained for subclasses: the full-height controller drives this - * internally, so overriding it does not intercept the embed event. - * @param data The event payload - */ - protected updateIFrameHeight = (data: MessagePayload): void => { - this.setIFrameHeight(Math.max(data.data, this.fullHeightController.minimumHeight)); - }; - /** * Gets the ThoughtSpot route of the page for a particular page ID. * @param pageId The identifier for a page in the ThoughtSpot app. @@ -1404,11 +1393,11 @@ export class AppEmbed extends V1Embed { */ public destroy() { super.destroy(); - this.fullHeightController.destroy(); + this.fullHeightController?.destroy(); } private postRender() { - this.fullHeightController.onRender(); + this.fullHeightController?.onRender(); } /** diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index 39fb3066..ca198e71 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -558,26 +558,28 @@ export interface LiveboardViewConfig export class LiveboardEmbed extends V1Embed { protected viewConfig: LiveboardViewConfig; - private readonly fullHeightController: FullHeightController; + private readonly fullHeightController?: FullHeightController; constructor(domSelector: DOMSelector, viewConfig: LiveboardViewConfig) { viewConfig.embedComponentType = 'LiveboardEmbed'; super(domSelector, viewConfig); - this.fullHeightController = new FullHeightController(this.viewConfig, { - getIframe: () => this.iFrame, - setFrameHeight: (height) => this.setIFrameHeight(height), - on: (eventType, callback) => { - this.on(eventType, callback); - }, - trigger: (hostEvent, data) => { - this.trigger(hostEvent, data); - }, - }); - if (this.viewConfig.fullHeight === true && this.viewConfig.vizId) { - logger.warn('Full height is currently only supported for Liveboard embeds.' - + 'Using full height with vizId might lead to unexpected behavior.'); + if (this.viewConfig.fullHeight === true) { + if (this.viewConfig.vizId) { + logger.warn('Full height is currently only supported for Liveboard embeds.' + + 'Using full height with vizId might lead to unexpected behavior.'); + } + this.fullHeightController = new FullHeightController(this.viewConfig, { + getIframe: () => this.iFrame, + setFrameHeight: (height) => this.setIFrameHeight(height), + on: (eventType, callback) => { + this.on(eventType, callback); + }, + trigger: (hostEvent, data) => { + this.trigger(hostEvent, data); + }, + }); + this.fullHeightController.registerEventHandlers(); } - this.fullHeightController.registerEventHandlers(); } protected async getAppInitData(): Promise { @@ -649,7 +651,7 @@ export class LiveboardEmbed extends V1Embed { const preventLiveboardFilterRemoval = this.viewConfig.preventLiveboardFilterRemoval || this.viewConfig.preventPinboardFilterRemoval; - this.fullHeightController.addQueryParams(params); + this.fullHeightController?.addQueryParams(params); if (enableVizTransformations !== undefined) { params[Param.EnableVizTransformations] = enableVizTransformations.toString(); } @@ -1005,11 +1007,11 @@ export class LiveboardEmbed extends V1Embed { */ public destroy() { super.destroy(); - this.fullHeightController.destroy(); + this.fullHeightController?.destroy(); } private postRender() { - this.fullHeightController.onRender(); + this.fullHeightController?.onRender(); } /** diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 396c35c5..7a7e7acb 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -5269,16 +5269,6 @@ describe('Additional Coverage Tests', () => { }); }); - test('should test getIframeCenter calculation', async () => { - const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); - await searchEmbed.render(); - await executeAfterWait(() => { - const center = searchEmbed['getIframeCenter'](); - expect(center).toHaveProperty('iframeCenter'); - expect(center).toHaveProperty('iframeHeight'); - expect(center).toHaveProperty('viewPortHeight'); - }); - }); test('should handle preRender with replaceExistingPreRender=true', async () => { createRootEleForEmbed(); diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 1dd193db..9831c567 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -23,7 +23,6 @@ import { AnswerService } from '../utils/graphql/answerService/answerService'; import { getEncodedQueryParamsString, getCssDimension, - calculateElementCenter, embedEventStatus, setAttributes, getCustomisations, @@ -1470,18 +1469,6 @@ export class TsEmbed { return V1EventMap[eventType] || eventType; } - /** - * Calculates the iframe center for the current visible viewPort - * of iframe using Scroll position of Host App, offsetTop for iframe - * in Host app. ViewPort height of the tab. - * @returns iframe Center in visible viewport, - * Iframe height, - * View port height. - */ - protected getIframeCenter() { - return calculateElementCenter(this.iFrame); - } - /** * Registers an event listener to trigger an alert when the ThoughtSpot app * sends an event of a particular message type to the host application. diff --git a/src/full-height.spec.ts b/src/full-height.spec.ts index 8b7c8153..feab541f 100644 --- a/src/full-height.spec.ts +++ b/src/full-height.spec.ts @@ -1,7 +1,9 @@ import { FullHeightController, FullHeightEmbedHost } from './full-height'; import { - EmbedEvent, FullHeightViewConfig, HostEvent, MessageCallback, Param, + BaseViewConfig, EmbedEvent, FullHeightViewConfig, HostEvent, MessageCallback, Param, } from './types'; + +type ControllerConfig = FullHeightViewConfig & Pick; import { logger } from './utils/logger'; import { DEFAULT_LAZY_LOADING_MARGIN } from './config'; @@ -10,9 +12,9 @@ describe('FullHeightController', () => { let host: FullHeightEmbedHost; let handlers: Map; - const createControllerFor = (viewConfig: FullHeightViewConfig) => createController(viewConfig); + const createControllerFor = (viewConfig: ControllerConfig) => createController(viewConfig); - const createController = (viewConfig: FullHeightViewConfig) => { + const createController = (viewConfig: ControllerConfig) => { handlers = new Map(); iFrame = document.createElement('iframe'); document.body.appendChild(iFrame); @@ -53,7 +55,7 @@ describe('FullHeightController', () => { describe('lazy loading defaults', () => { it('turns lazy loading on for a full-height embed', () => { - const viewConfig: FullHeightViewConfig = { fullHeight: true }; + const viewConfig: ControllerConfig = { fullHeight: true }; createControllerFor(viewConfig); expect(viewConfig.lazyLoadingForFullHeight).toBe(true); expect(viewConfig.enableScrollableContainerLazyLoading).toBe(true); @@ -61,7 +63,7 @@ describe('FullHeightController', () => { }); it('leaves an explicit opt-out alone', () => { - const viewConfig: FullHeightViewConfig = { + const viewConfig: ControllerConfig = { fullHeight: true, lazyLoadingForFullHeight: false, enableScrollableContainerLazyLoading: false, @@ -74,7 +76,7 @@ describe('FullHeightController', () => { }); it('defaults nothing when fullHeight is not enabled', () => { - const viewConfig: FullHeightViewConfig = {}; + const viewConfig: ControllerConfig = {}; createControllerFor(viewConfig); expect(viewConfig.lazyLoadingForFullHeight).toBeUndefined(); expect(viewConfig.enableScrollableContainerLazyLoading).toBeUndefined(); diff --git a/src/full-height.ts b/src/full-height.ts index d94d3b86..109eb3a1 100644 --- a/src/full-height.ts +++ b/src/full-height.ts @@ -6,6 +6,7 @@ */ import { + BaseViewConfig, EmbedEvent, FullHeightViewConfig, HostEvent, @@ -85,7 +86,7 @@ export class FullHeightController { private resizeObserver: ResizeObserver | undefined; constructor( - private readonly viewConfig: FullHeightViewConfig, + private readonly viewConfig: FullHeightViewConfig & Pick, private readonly host: FullHeightEmbedHost, ) { this.applyLazyLoadingDefaults(); diff --git a/src/types.ts b/src/types.ts index 34226395..461ae8aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10040,7 +10040,7 @@ export interface VisualizationOverrides { * enabled — keeps the app informed of the portion of the embed that is * currently visible so that visualizations load on demand. */ -export interface FullHeightViewConfig extends BaseViewConfig { +export interface FullHeightViewConfig { /** * If set to true, the embedded object container dynamically resizes * according to the height of the Liveboard. diff --git a/src/utils.spec.ts b/src/utils.spec.ts index 0e0863ce..e6dc2afc 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -32,6 +32,7 @@ import { deepMerge, getHostEventsConfig, isWindowUndefined, + calculateElementCenter, getOffsetTop, getDOMNode, getOperationNameFromQuery, @@ -1319,6 +1320,76 @@ describe('isWindowUndefined', () => { // --------------------------------------------------------------------------- // getOffsetTop // --------------------------------------------------------------------------- +describe('calculateElementCenter', () => { + const setViewport = (scrollY: number, innerHeight: number) => { + Object.defineProperty(window, 'scrollY', { value: scrollY, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: innerHeight, configurable: true }); + }; + + const mockElement = (offsetTop: number, offsetHeight: number) => ({ + getBoundingClientRect: () => ({ top: offsetTop - window.scrollY }), + offsetHeight, + }) as unknown as HTMLElement; + + test('centers on the element when it is shorter than the viewport', () => { + setViewport(0, 1000); + // Element sits at the top of the page and is 400px tall, fully visible. + const result = calculateElementCenter(mockElement(0, 400)); + expect(result).toEqual({ + iframeCenter: 200, + iframeScrolled: 0, + iframeHeight: 400, + viewPortHeight: 1000, + iframeVisibleViewPort: 400, + }); + }); + + test('centers on the visible slice when the element is taller than the viewport', () => { + setViewport(0, 600); + // 2000px element, only the top 600px is on screen. + const result = calculateElementCenter(mockElement(0, 2000)); + expect(result.iframeVisibleViewPort).toBe(600); + expect(result.iframeCenter).toBe(300); + expect(result.iframeScrolled).toBe(0); + }); + + test('tracks the centre as the page scrolls into the element', () => { + setViewport(500, 600); + // Scrolled 500px into a 2000px element that starts at the page top. + const result = calculateElementCenter(mockElement(0, 2000)); + expect(result.iframeScrolled).toBe(500); + // 600px still visible, offset by the 500px already scrolled past. + expect(result.iframeVisibleViewPort).toBe(600); + expect(result.iframeCenter).toBe(800); + }); + + test('clamps the visible slice to what is left of the element', () => { + setViewport(1800, 600); + // Only the last 200px of the 2000px element remains below the fold. + const result = calculateElementCenter(mockElement(0, 2000)); + expect(result.iframeVisibleViewPort).toBe(200); + expect(result.iframeCenter).toBe(1900); + }); + + test('measures only the on-screen part when the element starts below the fold', () => { + setViewport(0, 1000); + // Element begins 800px down, so 200px of it is visible. + const result = calculateElementCenter(mockElement(800, 400)); + expect(result.iframeScrolled).toBe(-800); + expect(result.iframeVisibleViewPort).toBe(200); + expect(result.iframeCenter).toBe(100); + }); + + test('never reports more visible height than the element has', () => { + setViewport(0, 1000); + // Element is 100px tall and starts 200px down: the viewport has room to + // spare, so the visible slice is the element itself, not the gap. + const result = calculateElementCenter(mockElement(200, 100)); + expect(result.iframeVisibleViewPort).toBe(100); + expect(result.iframeCenter).toBe(50); + }); +}); + describe('getOffsetTop', () => { test('returns rect.top + window.scrollY', () => { const mockElement = { From 1ecf2c222cfd4fe9cf5971f2a3f01fb1cc7749b6 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Mon, 31 Aug 2026 18:36:33 +0530 Subject: [PATCH 3/8] SCAL-334772 fixed gemini comment --- src/full-height.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/full-height.ts b/src/full-height.ts index 109eb3a1..c25a90b1 100644 --- a/src/full-height.ts +++ b/src/full-height.ts @@ -73,7 +73,7 @@ export interface FullHeightEmbedHost { } /** - * Owns every piece of full-height behaviour for an embed: the height + * Owns every piece of full-height behavior for an embed: the height * negotiation with the ThoughtSpot app, the query parameters that switch the * feature on, and the viewport listeners that drive lazy loading. * @@ -129,7 +129,7 @@ export class FullHeightController { /** * The floor for the frame height. `defaultHeight` is the deprecated - * spelling of `minimumHeight` and is still honoured for compatibility. + * spelling of `minimumHeight` and is still honored for compatibility. */ public get minimumHeight(): number { const { minimumHeight, defaultHeight } = this.viewConfig; @@ -191,7 +191,10 @@ export class FullHeightController { * never going below the configured minimum. */ private handleEmbedHeight = (payload: MessagePayload): void => { - this.host.setFrameHeight(Math.max(payload.data, this.minimumHeight)); + const height = Number(payload?.data); + if (!isNaN(height)) { + this.host.setFrameHeight(Math.max(height, this.minimumHeight)); + } this.sendVisibleCoordinates(); }; @@ -202,9 +205,13 @@ export class FullHeightController { payload: MessagePayload, responder?: (data: any) => void, ): void => { + const iframe = this.host.getIframe(); + if (!iframe) { + return; + } responder?.({ type: EmbedEvent.EmbedIframeCenter, - data: calculateElementCenter(this.host.getIframe()), + data: calculateElementCenter(iframe), }); }; @@ -227,7 +234,10 @@ export class FullHeightController { * since only Liveboard routes report a height of their own. */ private handleRouteChange = (payload: MessagePayload): void => { - const currentPath: string = payload.data.currentPath; + const currentPath: string = payload?.data?.currentPath; + if (!currentPath) { + return; + } if (LIVEBOARD_RELATED_ROUTES.some((route) => currentPath.startsWith(route))) { return; } @@ -246,8 +256,12 @@ export class FullHeightController { }; private getVisibleCoordinates() { + const iframe = this.host.getIframe(); + if (!iframe) { + return null; + } return calculateVisibleElementData( - this.host.getIframe(), + iframe, this.viewConfig.enableScrollableContainerLazyLoading, ); } From 435ca6581e600194e2109518c3cb46a98e2ad03e Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Mon, 31 Aug 2026 18:54:10 +0530 Subject: [PATCH 4/8] SCAL-334772 added test --- src/full-height.spec.ts | 499 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 494 insertions(+), 5 deletions(-) diff --git a/src/full-height.spec.ts b/src/full-height.spec.ts index feab541f..d9d17cf4 100644 --- a/src/full-height.spec.ts +++ b/src/full-height.spec.ts @@ -1,6 +1,11 @@ import { FullHeightController, FullHeightEmbedHost } from './full-height'; import { - BaseViewConfig, EmbedEvent, FullHeightViewConfig, HostEvent, MessageCallback, Param, + BaseViewConfig, + EmbedEvent, + FullHeightViewConfig, + HostEvent, + MessageCallback, + Param, } from './types'; type ControllerConfig = FullHeightViewConfig & Pick; @@ -12,14 +17,33 @@ describe('FullHeightController', () => { let host: FullHeightEmbedHost; let handlers: Map; + const originalResizeObserver = (window as any).ResizeObserver; + + /** + * A rect that only carries the edges the visibility maths reads. + */ + const rectOf = (top: number, bottom: number, left = 0, right = 500) => + ({ + top, + bottom, + left, + right, + width: right - left, + height: bottom - top, + }) as DOMRect; + const createControllerFor = (viewConfig: ControllerConfig) => createController(viewConfig); - const createController = (viewConfig: ControllerConfig) => { + const createController = ( + viewConfig: ControllerConfig, + options: { iframe?: HTMLIFrameElement } = {}, + ) => { handlers = new Map(); iFrame = document.createElement('iframe'); document.body.appendChild(iFrame); + const hostIframe = 'iframe' in options ? options.iframe : iFrame; host = { - getIframe: () => iFrame, + getIframe: () => hostIframe, setFrameHeight: jest.fn(), on: (eventType, callback) => { handlers.set(eventType, callback); @@ -33,6 +57,7 @@ describe('FullHeightController', () => { afterEach(() => { document.body.innerHTML = ''; + (window as any).ResizeObserver = originalResizeObserver; jest.restoreAllMocks(); }); @@ -51,6 +76,11 @@ describe('FullHeightController', () => { createController({}); expect(handlers.size).toBe(0); }); + + it('registers nothing when fullHeight is explicitly false', () => { + createController({ fullHeight: false, lazyLoadingForFullHeight: true }); + expect(handlers.size).toBe(0); + }); }); describe('lazy loading defaults', () => { @@ -75,6 +105,18 @@ describe('FullHeightController', () => { expect(viewConfig.lazyLoadingMargin).toBe('0px'); }); + it('defaults only the values the host app left unset', () => { + const viewConfig: ControllerConfig = { + fullHeight: true, + enableScrollableContainerLazyLoading: false, + lazyLoadingMargin: '50px', + }; + createControllerFor(viewConfig); + expect(viewConfig.lazyLoadingForFullHeight).toBe(true); + expect(viewConfig.enableScrollableContainerLazyLoading).toBe(false); + expect(viewConfig.lazyLoadingMargin).toBe('50px'); + }); + it('defaults nothing when fullHeight is not enabled', () => { const viewConfig: ControllerConfig = {}; createControllerFor(viewConfig); @@ -102,6 +144,28 @@ describe('FullHeightController', () => { const controller = createController({ fullHeight: true, defaultHeight: 700 }); expect(controller.minimumHeight).toBe(700); }); + + it('treats a zero minimumHeight as unset and honours defaultHeight', () => { + const controller = createController({ + fullHeight: true, + minimumHeight: 0, + defaultHeight: 700, + }); + expect(controller.minimumHeight).toBe(700); + }); + + it('falls back to 500 when both heights are zero', () => { + const controller = createController({ + fullHeight: true, + minimumHeight: 0, + defaultHeight: 0, + }); + expect(controller.minimumHeight).toBe(500); + }); + + it('is available even when fullHeight is off', () => { + expect(createController({ minimumHeight: 800 }).minimumHeight).toBe(800); + }); }); describe('addQueryParams', () => { @@ -134,8 +198,23 @@ describe('FullHeightController', () => { }); }); + it('adds the default margin when the host app does not set one', () => { + const params: any = {}; + createController({ fullHeight: true }).addQueryParams(params); + expect(params).toEqual({ + [Param.fullHeight]: true, + [Param.IsLazyLoadingForEmbedEnabled]: true, + [Param.RootMarginForLazyLoad]: DEFAULT_LAZY_LOADING_MARGIN, + }); + }); + + it('keeps the params the embed has already collected', () => { + const params: any = { existing: 'value' }; + createController({ fullHeight: true }).addQueryParams(params); + expect(params.existing).toBe('value'); + }); + it('drops an invalid lazy loading margin', () => { - // An invalid margin is reported to the developer, not sent on. const loggerError = jest.spyOn(logger, 'error').mockImplementation(jest.fn()); const params: any = {}; createController({ @@ -146,6 +225,31 @@ describe('FullHeightController', () => { expect(params[Param.RootMarginForLazyLoad]).toBeUndefined(); expect(loggerError).toHaveBeenCalled(); }); + + it('drops an empty lazy loading margin but keeps the other params', () => { + const loggerError = jest.spyOn(logger, 'error').mockImplementation(jest.fn()); + const params: any = {}; + createController({ + fullHeight: true, + lazyLoadingForFullHeight: true, + lazyLoadingMargin: '', + }).addQueryParams(params); + expect(params).toEqual({ + [Param.fullHeight]: true, + [Param.IsLazyLoadingForEmbedEnabled]: true, + }); + expect(loggerError).toHaveBeenCalled(); + }); + + it('accepts a unitless zero margin', () => { + const params: any = {}; + createController({ + fullHeight: true, + lazyLoadingForFullHeight: true, + lazyLoadingMargin: '0', + }).addQueryParams(params); + expect(params[Param.RootMarginForLazyLoad]).toBe('0'); + }); }); describe('EmbedHeight', () => { @@ -161,6 +265,45 @@ describe('FullHeightController', () => { expect(host.setFrameHeight).toHaveBeenCalledWith(1200); }); + it('never sizes the frame below the 500 default floor', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)({ data: 100 } as any); + expect(host.setFrameHeight).toHaveBeenCalledWith(500); + }); + + it('accepts a numeric height sent as a string', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)({ data: '1200' } as any); + expect(host.setFrameHeight).toHaveBeenCalledWith(1200); + }); + + it('leaves the height alone when the app reports a non-numeric height', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)({ data: 'tall' } as any); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('leaves the height alone when the payload carries no height', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)({} as any); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('survives a missing payload', () => { + createController({ fullHeight: true }); + expect(() => handlers.get(EmbedEvent.EmbedHeight)(undefined as any)).not.toThrow(); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('still pushes the visible coordinates for an unusable height', () => { + createController({ fullHeight: true, lazyLoadingForFullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)({ data: 'tall' } as any); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + }); + it('pushes the visible coordinates only when lazy loading is on', () => { createController({ fullHeight: true, lazyLoadingForFullHeight: false }); handlers.get(EmbedEvent.EmbedHeight)({ data: 1200 } as any); @@ -173,10 +316,16 @@ describe('FullHeightController', () => { expect.objectContaining({ top: expect.any(Number) }), ); }); + + it('pushes null coordinates when the iframe is not there yet', () => { + createController({ fullHeight: true }, { iframe: null }); + handlers.get(EmbedEvent.EmbedHeight)({ data: 1200 } as any); + expect(host.trigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, null); + }); }); describe('RouteChange', () => { - const routeChange = (currentPath: string) => ({ data: { currentPath } } as any); + const routeChange = (currentPath: string) => ({ data: { currentPath } }) as any; it('leaves the height alone while navigating within a Liveboard', () => { createController({ fullHeight: true }); @@ -184,17 +333,57 @@ describe('FullHeightController', () => { expect(host.setFrameHeight).not.toHaveBeenCalled(); }); + it.each([ + '/pinboard/abc', + '/insights/pinboard/abc', + '/schedules/abc', + '/embed/viz/abc', + '/embed/insights/viz/abc', + '/liveboard/abc', + '/insights/liveboard/abc', + '/tsl-editor/PINBOARD_ANSWER_BOOK/abc', + '/import-tsl/PINBOARD_ANSWER_BOOK/abc', + ])('leaves the height alone on the Liveboard route %s', (currentPath) => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.RouteChange)(routeChange(currentPath)); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + it('resets to frameParams.height when leaving the Liveboard routes', () => { createController({ fullHeight: true, frameParams: { height: 640 } }); handlers.get(EmbedEvent.RouteChange)(routeChange('/some/other/path/')); expect(host.setFrameHeight).toHaveBeenCalledWith(640); }); + it('passes a non-numeric frameParams.height through unchanged', () => { + createController({ fullHeight: true, frameParams: { height: '100%' } }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/some/other/path/')); + expect(host.setFrameHeight).toHaveBeenCalledWith('100%'); + }); + it('resets to the minimum height when frameParams has no height', () => { createController({ fullHeight: true, minimumHeight: 800 }); handlers.get(EmbedEvent.RouteChange)(routeChange('/some/other/path/')); expect(host.setFrameHeight).toHaveBeenCalledWith(800); }); + + it('resets when a Liveboard route only appears part way into the path', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/app/embed/viz/abc')); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + + it('leaves the height alone when the payload carries no path', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.RouteChange)({ data: {} } as any); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('survives a missing payload', () => { + createController({ fullHeight: true }); + expect(() => handlers.get(EmbedEvent.RouteChange)(undefined as any)).not.toThrow(); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); }); describe('coordinate requests', () => { @@ -217,6 +406,93 @@ describe('FullHeightController', () => { data: expect.objectContaining({ iframeCenter: expect.any(Number) }), }); }); + + it('logs the RequestVisibleEmbedCoordinates request', () => { + const loggerInfo = jest.spyOn(logger, 'info').mockImplementation(jest.fn()); + createController({ fullHeight: true }); + const payload = { type: EmbedEvent.RequestVisibleEmbedCoordinates } as any; + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)(payload, jest.fn()); + expect(loggerInfo).toHaveBeenCalledWith( + 'Sending RequestVisibleEmbedCoordinates', + payload, + ); + }); + + it('responds with null coordinates when the iframe is not there yet', () => { + createController({ fullHeight: true }, { iframe: null }); + const responder = jest.fn(); + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any, responder); + expect(responder).toHaveBeenCalledWith({ + type: EmbedEvent.RequestVisibleEmbedCoordinates, + data: null, + }); + }); + + it('stays silent on EmbedIframeCenter when the iframe is not there yet', () => { + createController({ fullHeight: true }, { iframe: null }); + const responder = jest.fn(); + handlers.get(EmbedEvent.EmbedIframeCenter)({} as any, responder); + expect(responder).not.toHaveBeenCalled(); + }); + + it('survives an app that asks for coordinates without a responder', () => { + createController({ fullHeight: true }); + expect(() => { + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any); + handlers.get(EmbedEvent.EmbedIframeCenter)({} as any); + }).not.toThrow(); + }); + + it('clips the visible region to the containers when container lazy loading is on', () => { + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + const controller = createController({ + fullHeight: true, + enableScrollableContainerLazyLoading: true, + }); + clippingContainer.appendChild(iFrame); + document.body.appendChild(clippingContainer); + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(-100, 400)); + jest.spyOn(clippingContainer, 'getBoundingClientRect').mockReturnValue(rectOf(50, 300)); + + const responder = jest.fn(); + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any, responder); + expect(responder).toHaveBeenCalledWith({ + type: EmbedEvent.RequestVisibleEmbedCoordinates, + data: { + top: 150, + height: 250, + left: 0, + width: 500, + }, + }); + controller.destroy(); + }); + + it('ignores the containers when container lazy loading is off', () => { + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + createController({ + fullHeight: true, + enableScrollableContainerLazyLoading: false, + }); + clippingContainer.appendChild(iFrame); + document.body.appendChild(clippingContainer); + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(-100, 400)); + jest.spyOn(clippingContainer, 'getBoundingClientRect').mockReturnValue(rectOf(50, 300)); + + const responder = jest.fn(); + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any, responder); + expect(responder).toHaveBeenCalledWith({ + type: EmbedEvent.RequestVisibleEmbedCoordinates, + data: { + top: 100, + height: 400, + left: 0, + width: 500, + }, + }); + }); }); describe('lazy load listeners', () => { @@ -246,6 +522,66 @@ describe('FullHeightController', () => { expect(add).not.toHaveBeenCalled(); }); + it('attaches nothing when fullHeight is off', () => { + const add = jest.spyOn(window, 'addEventListener'); + createController({ lazyLoadingForFullHeight: true }).onRender(); + expect(add).not.toHaveBeenCalled(); + }); + + it('attaches nothing when the iframe has not rendered yet', () => { + const add = jest.spyOn(window, 'addEventListener'); + createController({ fullHeight: true }, { iframe: null }).onRender(); + expect(add).not.toHaveBeenCalled(); + }); + + it('removes nothing on destroy when lazy loading is off', () => { + const remove = jest.spyOn(window, 'removeEventListener'); + createController({ + fullHeight: true, + lazyLoadingForFullHeight: false, + }).destroy(); + expect(remove).not.toHaveBeenCalled(); + }); + + it('pushes the visible coordinates on a window scroll and resize', () => { + const controller = createController({ fullHeight: true }); + controller.onRender(); + + window.dispatchEvent(new Event('scroll')); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + + (host.trigger as jest.Mock).mockClear(); + window.dispatchEvent(new Event('resize')); + expect(host.trigger).toHaveBeenCalledTimes(1); + + controller.destroy(); + (host.trigger as jest.Mock).mockClear(); + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('resize')); + expect(host.trigger).not.toHaveBeenCalled(); + }); + + it('does not stack duplicate window listeners across renders', () => { + const add = jest.spyOn(window, 'addEventListener'); + const controller = createController({ fullHeight: true }); + + controller.onRender(); + controller.onRender(); + const scrollHandlers = add.mock.calls + .filter(([eventType]) => eventType === 'scroll') + .map(([, handler]) => handler); + expect(scrollHandlers).toHaveLength(2); + expect(scrollHandlers[0]).toBe(scrollHandlers[1]); + + controller.destroy(); + (host.trigger as jest.Mock).mockClear(); + window.dispatchEvent(new Event('scroll')); + expect(host.trigger).not.toHaveBeenCalled(); + }); + it('observes the scrollable ancestors when container lazy loading is on', () => { const observe = jest.fn(); const disconnect = jest.fn(); @@ -272,5 +608,158 @@ describe('FullHeightController', () => { expect(removeContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); expect(disconnect).toHaveBeenCalled(); }); + + it('pushes the visible coordinates on a container scroll', () => { + (window as any).ResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + disconnect: jest.fn(), + })); + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + + const controller = createController({ fullHeight: true }); + scrollContainer.appendChild(iFrame); + document.body.appendChild(scrollContainer); + controller.onRender(); + + scrollContainer.dispatchEvent(new Event('scroll')); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + + controller.destroy(); + (host.trigger as jest.Mock).mockClear(); + scrollContainer.dispatchEvent(new Event('scroll')); + expect(host.trigger).not.toHaveBeenCalled(); + }); + + it('pushes the visible coordinates when an observed container resizes', () => { + let resizeCallback: () => void; + (window as any).ResizeObserver = jest.fn((callback) => { + resizeCallback = callback; + return { observe: jest.fn(), disconnect: jest.fn() }; + }); + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + + const controller = createController({ fullHeight: true }); + scrollContainer.appendChild(iFrame); + document.body.appendChild(scrollContainer); + controller.onRender(); + + resizeCallback(); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + controller.destroy(); + }); + + it('observes the iframe parent even without a scrollable ancestor', () => { + const observe = jest.fn(); + (window as any).ResizeObserver = jest.fn(() => ({ + observe, + disconnect: jest.fn(), + })); + + const controller = createController({ fullHeight: true }); + controller.onRender(); + expect(observe).toHaveBeenCalledWith(iFrame.parentElement); + controller.destroy(); + }); + + it('observes each resize target only once', () => { + const observe = jest.fn(); + (window as any).ResizeObserver = jest.fn(() => ({ + observe, + disconnect: jest.fn(), + })); + const container = document.createElement('div'); + container.style.overflow = 'auto'; + + const controller = createController({ fullHeight: true }); + container.appendChild(iFrame); + document.body.appendChild(container); + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(-100, 400)); + jest.spyOn(container, 'getBoundingClientRect').mockReturnValue(rectOf(50, 300)); + + controller.onRender(); + expect(observe).toHaveBeenCalledTimes(1); + expect(observe).toHaveBeenCalledWith(container); + controller.destroy(); + }); + + it('does not touch the containers when container lazy loading is off', () => { + const resizeObserver = jest.fn(); + (window as any).ResizeObserver = resizeObserver; + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + const addContainerListener = jest.spyOn(scrollContainer, 'addEventListener'); + + const controller = createController({ + fullHeight: true, + lazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: false, + }); + scrollContainer.appendChild(iFrame); + document.body.appendChild(scrollContainer); + + controller.onRender(); + expect(addContainerListener).not.toHaveBeenCalled(); + expect(resizeObserver).not.toHaveBeenCalled(); + }); + + it('still tracks the containers in an environment without ResizeObserver', () => { + delete (window as any).ResizeObserver; + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + const addContainerListener = jest.spyOn(scrollContainer, 'addEventListener'); + + const controller = createController({ fullHeight: true }); + scrollContainer.appendChild(iFrame); + document.body.appendChild(scrollContainer); + + expect(() => controller.onRender()).not.toThrow(); + expect(addContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(() => controller.destroy()).not.toThrow(); + }); + + it('drops the previous containers when the embed re-renders', () => { + const disconnect = jest.fn(); + (window as any).ResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + disconnect, + })); + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + const removeContainerListener = jest.spyOn(scrollContainer, 'removeEventListener'); + + const controller = createController({ fullHeight: true }); + scrollContainer.appendChild(iFrame); + document.body.appendChild(scrollContainer); + + controller.onRender(); + controller.onRender(); + expect(removeContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(disconnect).toHaveBeenCalledTimes(1); + controller.destroy(); + }); + + it('is safe to destroy more than once', () => { + (window as any).ResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + disconnect: jest.fn(), + })); + const controller = createController({ fullHeight: true }); + controller.onRender(); + controller.destroy(); + expect(() => controller.destroy()).not.toThrow(); + }); + + it('is safe to destroy before the embed has rendered', () => { + const controller = createController({ fullHeight: true }); + expect(() => controller.destroy()).not.toThrow(); + }); }); }); From ca931d0486a7047d50eac2c91854648cfc08e94c Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Mon, 31 Aug 2026 19:40:56 +0530 Subject: [PATCH 5/8] SCAL-334772 fixed gemini comment --- src/embed/app.spec.ts | 40 +-- src/embed/liveboard.spec.ts | 40 ++- src/full-height.spec.ts | 569 ++++++++++++++++++++++++------------ src/full-height.ts | 12 +- src/types.ts | 2 +- src/utils.spec.ts | 2 +- 6 files changed, 427 insertions(+), 238 deletions(-) diff --git a/src/embed/app.spec.ts b/src/embed/app.spec.ts index ae550de0..3389761b 100644 --- a/src/embed/app.spec.ts +++ b/src/embed/app.spec.ts @@ -2178,10 +2178,6 @@ describe('App embed tests', () => { fullHeight: true, } as AppViewConfig); - expect((appEmbed as any).viewConfig.lazyLoadingForFullHeight).toBe(true); - expect((appEmbed as any).viewConfig.enableScrollableContainerLazyLoading).toBe(true); - expect((appEmbed as any).viewConfig.lazyLoadingMargin).toBe('500px 0px'); - await appEmbed.render(); await executeAfterWait(() => { @@ -2197,11 +2193,14 @@ describe('App embed tests', () => { ...defaultViewConfig, } as AppViewConfig); - expect((appEmbed as any).viewConfig.lazyLoadingForFullHeight).toBeUndefined(); - expect( - (appEmbed as any).viewConfig.enableScrollableContainerLazyLoading, - ).toBeUndefined(); - expect((appEmbed as any).viewConfig.lazyLoadingMargin).toBeUndefined(); + await appEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isFullHeightPinboard'); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled'); + expect(iframeSrc).not.toContain('rootMarginForLazyLoad'); + }, 100); }); test('should not write the defaults back onto the caller view config', async () => { @@ -2223,11 +2222,14 @@ describe('App embed tests', () => { fullHeight: false, } as AppViewConfig); - expect((appEmbed as any).viewConfig.lazyLoadingForFullHeight).toBeUndefined(); - expect( - (appEmbed as any).viewConfig.enableScrollableContainerLazyLoading, - ).toBeUndefined(); - expect((appEmbed as any).viewConfig.lazyLoadingMargin).toBeUndefined(); + await appEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isFullHeightPinboard'); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled'); + expect(iframeSrc).not.toContain('rootMarginForLazyLoad'); + }, 100); }); test('should default lazyLoadingMargin when lazyLoadingForFullHeight is set explicitly', async () => { @@ -2237,14 +2239,14 @@ describe('App embed tests', () => { lazyLoadingForFullHeight: true, } as AppViewConfig); - expect((appEmbed as any).viewConfig.lazyLoadingMargin).toBe( - config.DEFAULT_LAZY_LOADING_MARGIN, - ); - await appEmbed.render(); await executeAfterWait(() => { - expect(getIFrameSrc()).toContain('rootMarginForLazyLoad=500px%200px'); + expect(getIFrameSrc()).toContain( + `rootMarginForLazyLoad=${encodeURIComponent( + config.DEFAULT_LAZY_LOADING_MARGIN, + )}`, + ); }, 100); }); diff --git a/src/embed/liveboard.spec.ts b/src/embed/liveboard.spec.ts index 8da5b9d3..fbb55c06 100644 --- a/src/embed/liveboard.spec.ts +++ b/src/embed/liveboard.spec.ts @@ -2059,12 +2059,6 @@ describe('Liveboard/viz embed tests', () => { fullHeight: true, } as LiveboardViewConfig); - expect((liveboardEmbed as any).viewConfig.lazyLoadingForFullHeight).toBe(true); - expect( - (liveboardEmbed as any).viewConfig.enableScrollableContainerLazyLoading, - ).toBe(true); - expect((liveboardEmbed as any).viewConfig.lazyLoadingMargin).toBe('500px 0px'); - await liveboardEmbed.render(); await executeAfterWait(() => { @@ -2081,11 +2075,14 @@ describe('Liveboard/viz embed tests', () => { liveboardId, } as LiveboardViewConfig); - expect((liveboardEmbed as any).viewConfig.lazyLoadingForFullHeight).toBeUndefined(); - expect( - (liveboardEmbed as any).viewConfig.enableScrollableContainerLazyLoading, - ).toBeUndefined(); - expect((liveboardEmbed as any).viewConfig.lazyLoadingMargin).toBeUndefined(); + await liveboardEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isFullHeightPinboard'); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled'); + expect(iframeSrc).not.toContain('rootMarginForLazyLoad'); + }, 100); }); test('should not write the defaults back onto the caller view config', async () => { @@ -2109,11 +2106,14 @@ describe('Liveboard/viz embed tests', () => { fullHeight: false, } as LiveboardViewConfig); - expect((liveboardEmbed as any).viewConfig.lazyLoadingForFullHeight).toBeUndefined(); - expect( - (liveboardEmbed as any).viewConfig.enableScrollableContainerLazyLoading, - ).toBeUndefined(); - expect((liveboardEmbed as any).viewConfig.lazyLoadingMargin).toBeUndefined(); + await liveboardEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isFullHeightPinboard'); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled'); + expect(iframeSrc).not.toContain('rootMarginForLazyLoad'); + }, 100); }); test('should default lazyLoadingMargin when lazyLoadingForFullHeight is set explicitly', async () => { @@ -2124,14 +2124,12 @@ describe('Liveboard/viz embed tests', () => { lazyLoadingForFullHeight: true, } as LiveboardViewConfig); - expect((liveboardEmbed as any).viewConfig.lazyLoadingMargin).toBe( - DEFAULT_LAZY_LOADING_MARGIN, - ); - await liveboardEmbed.render(); await executeAfterWait(() => { - expect(getIFrameSrc()).toContain('rootMarginForLazyLoad=500px%200px'); + expect(getIFrameSrc()).toContain( + `rootMarginForLazyLoad=${encodeURIComponent(DEFAULT_LAZY_LOADING_MARGIN)}`, + ); }, 100); }); diff --git a/src/full-height.spec.ts b/src/full-height.spec.ts index d9d17cf4..612511d2 100644 --- a/src/full-height.spec.ts +++ b/src/full-height.spec.ts @@ -1,11 +1,6 @@ import { FullHeightController, FullHeightEmbedHost } from './full-height'; import { - BaseViewConfig, - EmbedEvent, - FullHeightViewConfig, - HostEvent, - MessageCallback, - Param, + BaseViewConfig, EmbedEvent, FullHeightViewConfig, HostEvent, MessageCallback, Param, } from './types'; type ControllerConfig = FullHeightViewConfig & Pick; @@ -18,19 +13,27 @@ describe('FullHeightController', () => { let handlers: Map; const originalResizeObserver = (window as any).ResizeObserver; + const originalScrollY = window.scrollY; + const originalInnerHeight = window.innerHeight; + const originalInnerWidth = window.innerWidth; /** - * A rect that only carries the edges the visibility maths reads. + * A rect that only carries the edges the visibility math reads. */ - const rectOf = (top: number, bottom: number, left = 0, right = 500) => - ({ - top, - bottom, - left, - right, - width: right - left, - height: bottom - top, - }) as DOMRect; + const rectOf = (top: number, bottom: number, left = 0, right = 500) => ({ + top, + bottom, + left, + right, + width: right - left, + height: bottom - top, + } as DOMRect); + + const setViewport = (scrollY: number, innerHeight: number, innerWidth = 1024) => { + Object.defineProperty(window, 'scrollY', { value: scrollY, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: innerHeight, configurable: true }); + Object.defineProperty(window, 'innerWidth', { value: innerWidth, configurable: true }); + }; const createControllerFor = (viewConfig: ControllerConfig) => createController(viewConfig); @@ -55,9 +58,70 @@ describe('FullHeightController', () => { return controller; }; + /** + * The query params a freshly built controller contributes. The defaults are + * applied to the controller's private copy of the view config, so the + * params are how the host app observes them. + */ + const queryParamsFor = (viewConfig: ControllerConfig) => { + const params: any = {}; + createController(viewConfig).addQueryParams(params); + return params; + }; + + /** + * Puts the embed inside a scrollable container and hands back the spies the + * container assertions need. The caller drives `onRender`, so tests can + * assert what happens before it too. + */ + const mountInScrollContainer = ( + viewConfig: ControllerConfig, + options: { withResizeObserver?: boolean } = {}, + ) => { + const observe = jest.fn(); + const disconnect = jest.fn(); + let resizeCallback: () => void; + const resizeObserverCtor = jest.fn((callback: () => void) => { + resizeCallback = callback; + return { observe, disconnect }; + }); + if (options.withResizeObserver === false) { + delete (window as any).ResizeObserver; + } else { + (window as any).ResizeObserver = resizeObserverCtor; + } + + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + const addContainerListener = jest.spyOn(scrollContainer, 'addEventListener'); + const removeContainerListener = jest.spyOn(scrollContainer, 'removeEventListener'); + + const controller = createController(viewConfig); + scrollContainer.appendChild(iFrame); + document.body.appendChild(scrollContainer); + + return { + controller, + scrollContainer, + addContainerListener, + removeContainerListener, + observe, + disconnect, + resizeObserverCtor, + fireResizeObserver: () => resizeCallback(), + }; + }; + + const visibleCoordinates = () => { + const responder = jest.fn(); + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any, responder); + return responder.mock.calls[0][0].data; + }; + afterEach(() => { document.body.innerHTML = ''; (window as any).ResizeObserver = originalResizeObserver; + setViewport(originalScrollY, originalInnerHeight, originalInnerWidth); jest.restoreAllMocks(); }); @@ -78,6 +142,7 @@ describe('FullHeightController', () => { }); it('registers nothing when fullHeight is explicitly false', () => { + // Lazy loading on its own must not switch the feature on. createController({ fullHeight: false, lazyLoadingForFullHeight: true }); expect(handlers.size).toBe(0); }); @@ -85,44 +150,84 @@ describe('FullHeightController', () => { describe('lazy loading defaults', () => { it('turns lazy loading on for a full-height embed', () => { + expect(queryParamsFor({ fullHeight: true })).toEqual({ + [Param.fullHeight]: true, + [Param.IsLazyLoadingForEmbedEnabled]: true, + [Param.RootMarginForLazyLoad]: DEFAULT_LAZY_LOADING_MARGIN, + }); + }); + + it('tracks the scrollable containers without the host app opting in', () => { + const { controller, addContainerListener, observe } = mountInScrollContainer({ + fullHeight: true, + }); + controller.onRender(); + expect(addContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(observe).toHaveBeenCalled(); + controller.destroy(); + }); + + it('leaves the view config the host app passed in untouched', () => { + // The controller defaults its own copy, so the host app's object + // never grows keys it did not set. const viewConfig: ControllerConfig = { fullHeight: true }; createControllerFor(viewConfig); - expect(viewConfig.lazyLoadingForFullHeight).toBe(true); - expect(viewConfig.enableScrollableContainerLazyLoading).toBe(true); - expect(viewConfig.lazyLoadingMargin).toBe(DEFAULT_LAZY_LOADING_MARGIN); + expect(viewConfig).toEqual({ fullHeight: true }); }); it('leaves an explicit opt-out alone', () => { - const viewConfig: ControllerConfig = { + expect(queryParamsFor({ fullHeight: true, lazyLoadingForFullHeight: false, - enableScrollableContainerLazyLoading: false, lazyLoadingMargin: '0px', - }; - createControllerFor(viewConfig); - expect(viewConfig.lazyLoadingForFullHeight).toBe(false); - expect(viewConfig.enableScrollableContainerLazyLoading).toBe(false); - expect(viewConfig.lazyLoadingMargin).toBe('0px'); + })).toEqual({ [Param.fullHeight]: true }); + }); + + it('honours an explicit container opt-out', () => { + const { controller, addContainerListener, resizeObserverCtor } = mountInScrollContainer( + { fullHeight: true, enableScrollableContainerLazyLoading: false }, + ); + controller.onRender(); + expect(addContainerListener).not.toHaveBeenCalled(); + expect(resizeObserverCtor).not.toHaveBeenCalled(); }); it('defaults only the values the host app left unset', () => { - const viewConfig: ControllerConfig = { + // Lazy loading defaults on, while the supplied margin survives. + const { controller, addContainerListener } = mountInScrollContainer({ fullHeight: true, enableScrollableContainerLazyLoading: false, lazyLoadingMargin: '50px', - }; - createControllerFor(viewConfig); - expect(viewConfig.lazyLoadingForFullHeight).toBe(true); - expect(viewConfig.enableScrollableContainerLazyLoading).toBe(false); - expect(viewConfig.lazyLoadingMargin).toBe('50px'); + }); + const params: any = {}; + controller.addQueryParams(params); + expect(params).toEqual({ + [Param.fullHeight]: true, + [Param.IsLazyLoadingForEmbedEnabled]: true, + [Param.RootMarginForLazyLoad]: '50px', + }); + + controller.onRender(); + expect(addContainerListener).not.toHaveBeenCalled(); }); it('defaults nothing when fullHeight is not enabled', () => { const viewConfig: ControllerConfig = {}; - createControllerFor(viewConfig); - expect(viewConfig.lazyLoadingForFullHeight).toBeUndefined(); - expect(viewConfig.enableScrollableContainerLazyLoading).toBeUndefined(); - expect(viewConfig.lazyLoadingMargin).toBeUndefined(); + const controller = createControllerFor(viewConfig); + expect(viewConfig).toEqual({}); + + const add = jest.spyOn(window, 'addEventListener'); + controller.onRender(); + expect(add).not.toHaveBeenCalled(); + }); + + it('ignores changes the host app makes to its config after construction', () => { + const viewConfig: ControllerConfig = { fullHeight: true, minimumHeight: 800 }; + const controller = createControllerFor(viewConfig); + viewConfig.minimumHeight = 900; + viewConfig.fullHeight = false; + expect(controller.minimumHeight).toBe(800); + expect(queryParamsFor(viewConfig)[Param.fullHeight]).toBeUndefined(); }); }); @@ -170,42 +275,40 @@ describe('FullHeightController', () => { describe('addQueryParams', () => { it('adds no params when fullHeight is not enabled', () => { - const params: any = {}; - createController({ lazyLoadingForFullHeight: true }).addQueryParams(params); - expect(params).toEqual({}); + expect(queryParamsFor({ lazyLoadingForFullHeight: true })).toEqual({}); }); it('adds only the full height param when lazy loading is off', () => { - const params: any = {}; - createController({ + expect(queryParamsFor({ fullHeight: true, lazyLoadingForFullHeight: false, - }).addQueryParams(params); - expect(params).toEqual({ [Param.fullHeight]: true }); + })).toEqual({ [Param.fullHeight]: true }); }); it('adds the lazy loading params, including a valid margin', () => { - const params: any = {}; - createController({ + expect(queryParamsFor({ fullHeight: true, lazyLoadingForFullHeight: true, lazyLoadingMargin: '100px 0px', - }).addQueryParams(params); - expect(params).toEqual({ + })).toEqual({ [Param.fullHeight]: true, [Param.IsLazyLoadingForEmbedEnabled]: true, [Param.RootMarginForLazyLoad]: '100px 0px', }); }); - it('adds the default margin when the host app does not set one', () => { - const params: any = {}; - createController({ fullHeight: true }).addQueryParams(params); - expect(params).toEqual({ - [Param.fullHeight]: true, - [Param.IsLazyLoadingForEmbedEnabled]: true, - [Param.RootMarginForLazyLoad]: DEFAULT_LAZY_LOADING_MARGIN, - }); + it('accepts a four sided margin', () => { + expect(queryParamsFor({ + fullHeight: true, + lazyLoadingMargin: '10px 20px 30px 40px', + })[Param.RootMarginForLazyLoad]).toBe('10px 20px 30px 40px'); + }); + + it('accepts a unitless zero margin', () => { + expect(queryParamsFor({ + fullHeight: true, + lazyLoadingMargin: '0', + })[Param.RootMarginForLazyLoad]).toBe('0'); }); it('keeps the params the embed has already collected', () => { @@ -214,72 +317,111 @@ describe('FullHeightController', () => { expect(params.existing).toBe('value'); }); + it('produces the same params when called for a second render', () => { + const controller = createController({ fullHeight: true }); + const first: any = {}; + const second: any = {}; + controller.addQueryParams(first); + controller.addQueryParams(second); + expect(second).toEqual(first); + }); + it('drops an invalid lazy loading margin', () => { + // An invalid margin is reported to the developer, not sent on. const loggerError = jest.spyOn(logger, 'error').mockImplementation(jest.fn()); - const params: any = {}; - createController({ + const params = queryParamsFor({ fullHeight: true, lazyLoadingForFullHeight: true, lazyLoadingMargin: 'not-a-margin', - }).addQueryParams(params); + }); + expect(params[Param.RootMarginForLazyLoad]).toBeUndefined(); + expect(loggerError).toHaveBeenCalled(); + }); + + it('drops a margin with more than four sides', () => { + const loggerError = jest.spyOn(logger, 'error').mockImplementation(jest.fn()); + const params = queryParamsFor({ + fullHeight: true, + lazyLoadingMargin: '1px 2px 3px 4px 5px', + }); + expect(params[Param.RootMarginForLazyLoad]).toBeUndefined(); + expect(loggerError).toHaveBeenCalled(); + }); + + it('drops a margin that is not a string', () => { + const loggerError = jest.spyOn(logger, 'error').mockImplementation(jest.fn()); + const params = queryParamsFor({ + fullHeight: true, + lazyLoadingMargin: 100 as any, + }); expect(params[Param.RootMarginForLazyLoad]).toBeUndefined(); expect(loggerError).toHaveBeenCalled(); }); it('drops an empty lazy loading margin but keeps the other params', () => { const loggerError = jest.spyOn(logger, 'error').mockImplementation(jest.fn()); - const params: any = {}; - createController({ + const params = queryParamsFor({ fullHeight: true, lazyLoadingForFullHeight: true, lazyLoadingMargin: '', - }).addQueryParams(params); + }); expect(params).toEqual({ [Param.fullHeight]: true, [Param.IsLazyLoadingForEmbedEnabled]: true, }); expect(loggerError).toHaveBeenCalled(); }); - - it('accepts a unitless zero margin', () => { - const params: any = {}; - createController({ - fullHeight: true, - lazyLoadingForFullHeight: true, - lazyLoadingMargin: '0', - }).addQueryParams(params); - expect(params[Param.RootMarginForLazyLoad]).toBe('0'); - }); }); describe('EmbedHeight', () => { + const embedHeight = (data: unknown) => ({ data } as any); + it('never sizes the frame below the configured minimum', () => { createController({ fullHeight: true, minimumHeight: 800 }); - handlers.get(EmbedEvent.EmbedHeight)({ data: 300 } as any); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(300)); expect(host.setFrameHeight).toHaveBeenCalledWith(800); }); it('uses the height reported by the app when it clears the minimum', () => { createController({ fullHeight: true, minimumHeight: 800 }); - handlers.get(EmbedEvent.EmbedHeight)({ data: 1200 } as any); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(1200)); expect(host.setFrameHeight).toHaveBeenCalledWith(1200); }); it('never sizes the frame below the 500 default floor', () => { createController({ fullHeight: true }); - handlers.get(EmbedEvent.EmbedHeight)({ data: 100 } as any); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(100)); expect(host.setFrameHeight).toHaveBeenCalledWith(500); }); + it('clamps a negative height to the minimum', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(-50)); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + + it('clamps a zero height to the minimum', () => { + // Zero is a height the app really reported, not a missing one. + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(0)); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + + it('keeps a fractional height that clears the minimum', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(1200.5)); + expect(host.setFrameHeight).toHaveBeenCalledWith(1200.5); + }); + it('accepts a numeric height sent as a string', () => { createController({ fullHeight: true }); - handlers.get(EmbedEvent.EmbedHeight)({ data: '1200' } as any); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight('1200')); expect(host.setFrameHeight).toHaveBeenCalledWith(1200); }); it('leaves the height alone when the app reports a non-numeric height', () => { createController({ fullHeight: true }); - handlers.get(EmbedEvent.EmbedHeight)({ data: 'tall' } as any); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight('tall')); expect(host.setFrameHeight).not.toHaveBeenCalled(); }); @@ -297,7 +439,7 @@ describe('FullHeightController', () => { it('still pushes the visible coordinates for an unusable height', () => { createController({ fullHeight: true, lazyLoadingForFullHeight: true }); - handlers.get(EmbedEvent.EmbedHeight)({ data: 'tall' } as any); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight('tall')); expect(host.trigger).toHaveBeenCalledWith( HostEvent.VisibleEmbedCoordinates, expect.objectContaining({ top: expect.any(Number) }), @@ -306,26 +448,28 @@ describe('FullHeightController', () => { it('pushes the visible coordinates only when lazy loading is on', () => { createController({ fullHeight: true, lazyLoadingForFullHeight: false }); - handlers.get(EmbedEvent.EmbedHeight)({ data: 1200 } as any); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(1200)); expect(host.trigger).not.toHaveBeenCalled(); createController({ fullHeight: true, lazyLoadingForFullHeight: true }); - handlers.get(EmbedEvent.EmbedHeight)({ data: 1200 } as any); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(1200)); expect(host.trigger).toHaveBeenCalledWith( HostEvent.VisibleEmbedCoordinates, expect.objectContaining({ top: expect.any(Number) }), ); }); - it('pushes null coordinates when the iframe is not there yet', () => { + it('pushes no coordinates when the iframe is not there yet', () => { + // There is nothing to measure, so the app is left alone. createController({ fullHeight: true }, { iframe: null }); - handlers.get(EmbedEvent.EmbedHeight)({ data: 1200 } as any); - expect(host.trigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, null); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(1200)); + expect(host.setFrameHeight).toHaveBeenCalledWith(1200); + expect(host.trigger).not.toHaveBeenCalled(); }); }); describe('RouteChange', () => { - const routeChange = (currentPath: string) => ({ data: { currentPath } }) as any; + const routeChange = (currentPath: string) => ({ data: { currentPath } } as any); it('leaves the height alone while navigating within a Liveboard', () => { createController({ fullHeight: true }); @@ -349,6 +493,12 @@ describe('FullHeightController', () => { expect(host.setFrameHeight).not.toHaveBeenCalled(); }); + it('leaves the height alone on a bare Liveboard route', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/liveboard/')); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + it('resets to frameParams.height when leaving the Liveboard routes', () => { createController({ fullHeight: true, frameParams: { height: 640 } }); handlers.get(EmbedEvent.RouteChange)(routeChange('/some/other/path/')); @@ -368,11 +518,18 @@ describe('FullHeightController', () => { }); it('resets when a Liveboard route only appears part way into the path', () => { + // The routes are matched as prefixes, not as substrings. createController({ fullHeight: true, minimumHeight: 800 }); handlers.get(EmbedEvent.RouteChange)(routeChange('/app/embed/viz/abc')); expect(host.setFrameHeight).toHaveBeenCalledWith(800); }); + it('resets on a Liveboard route missing its trailing slash', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/liveboard')); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + it('leaves the height alone when the payload carries no path', () => { createController({ fullHeight: true }); handlers.get(EmbedEvent.RouteChange)({ data: {} } as any); @@ -384,6 +541,13 @@ describe('FullHeightController', () => { expect(() => handlers.get(EmbedEvent.RouteChange)(undefined as any)).not.toThrow(); expect(host.setFrameHeight).not.toHaveBeenCalled(); }); + + it('does not push coordinates on a route change', () => { + // Only a height report and the viewport listeners do that. + createController({ fullHeight: true }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/some/other/path/')); + expect(host.trigger).not.toHaveBeenCalled(); + }); }); describe('coordinate requests', () => { @@ -407,6 +571,41 @@ describe('FullHeightController', () => { }); }); + it('measures the center against the viewport for an unscrolled page', () => { + setViewport(0, 768); + createController({ fullHeight: true }); + Object.defineProperty(iFrame, 'offsetHeight', { value: 1000, configurable: true }); + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(100, 1100)); + + const responder = jest.fn(); + handlers.get(EmbedEvent.EmbedIframeCenter)({} as any, responder); + expect(responder.mock.calls[0][0].data).toEqual({ + iframeCenter: 334, + iframeScrolled: -100, + iframeHeight: 1000, + viewPortHeight: 768, + iframeVisibleViewPort: 668, + }); + }); + + it('measures the center against the viewport for a scrolled page', () => { + setViewport(500, 600); + createController({ fullHeight: true }); + Object.defineProperty(iFrame, 'offsetHeight', { value: 2000, configurable: true }); + // The element starts at the page top, so it is 500px scrolled. + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(-500, 1500)); + + const responder = jest.fn(); + handlers.get(EmbedEvent.EmbedIframeCenter)({} as any, responder); + expect(responder.mock.calls[0][0].data).toEqual({ + iframeCenter: 800, + iframeScrolled: 500, + iframeHeight: 2000, + viewPortHeight: 600, + iframeVisibleViewPort: 600, + }); + }); + it('logs the RequestVisibleEmbedCoordinates request', () => { const loggerInfo = jest.spyOn(logger, 'info').mockImplementation(jest.fn()); createController({ fullHeight: true }); @@ -443,33 +642,36 @@ describe('FullHeightController', () => { }).not.toThrow(); }); + it('reports the region left uncovered when the embed is off screen', () => { + setViewport(0, 768); + createController({ fullHeight: true }); + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(900, 1400)); + expect(visibleCoordinates()).toEqual({ + top: 0, height: 0, left: 0, width: 500, + }); + }); + it('clips the visible region to the containers when container lazy loading is on', () => { + setViewport(0, 768); const clippingContainer = document.createElement('div'); clippingContainer.style.overflow = 'hidden'; - const controller = createController({ + createController({ fullHeight: true, enableScrollableContainerLazyLoading: true, }); clippingContainer.appendChild(iFrame); document.body.appendChild(clippingContainer); jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(-100, 400)); - jest.spyOn(clippingContainer, 'getBoundingClientRect').mockReturnValue(rectOf(50, 300)); + jest.spyOn(clippingContainer, 'getBoundingClientRect') + .mockReturnValue(rectOf(50, 300)); - const responder = jest.fn(); - handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any, responder); - expect(responder).toHaveBeenCalledWith({ - type: EmbedEvent.RequestVisibleEmbedCoordinates, - data: { - top: 150, - height: 250, - left: 0, - width: 500, - }, + expect(visibleCoordinates()).toEqual({ + top: 150, height: 250, left: 0, width: 500, }); - controller.destroy(); }); it('ignores the containers when container lazy loading is off', () => { + setViewport(0, 768); const clippingContainer = document.createElement('div'); clippingContainer.style.overflow = 'hidden'; createController({ @@ -479,18 +681,27 @@ describe('FullHeightController', () => { clippingContainer.appendChild(iFrame); document.body.appendChild(clippingContainer); jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(-100, 400)); - jest.spyOn(clippingContainer, 'getBoundingClientRect').mockReturnValue(rectOf(50, 300)); + jest.spyOn(clippingContainer, 'getBoundingClientRect') + .mockReturnValue(rectOf(50, 300)); - const responder = jest.fn(); - handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any, responder); - expect(responder).toHaveBeenCalledWith({ - type: EmbedEvent.RequestVisibleEmbedCoordinates, - data: { - top: 100, - height: 400, - left: 0, - width: 500, - }, + expect(visibleCoordinates()).toEqual({ + top: 100, height: 400, left: 0, width: 500, + }); + }); + + it('clips the visible region horizontally too', () => { + setViewport(0, 768, 1024); + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + createController({ fullHeight: true }); + clippingContainer.appendChild(iFrame); + document.body.appendChild(clippingContainer); + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(0, 400, -60, 600)); + jest.spyOn(clippingContainer, 'getBoundingClientRect') + .mockReturnValue(rectOf(0, 400, 40, 500)); + + expect(visibleCoordinates()).toEqual({ + top: 0, height: 400, left: 100, width: 460, }); }); }); @@ -564,6 +775,33 @@ describe('FullHeightController', () => { expect(host.trigger).not.toHaveBeenCalled(); }); + it('pushes the visible coordinates for a scroll inside a nested element', () => { + // The window scroll listener is registered in the capture phase, so + // a scroll on an inner element reaches it too. + const controller = createController({ fullHeight: true }); + controller.onRender(); + iFrame.dispatchEvent(new Event('scroll', { bubbles: false })); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + controller.destroy(); + }); + + it('picks the listeners back up when the embed re-renders after destroy', () => { + const controller = createController({ fullHeight: true }); + controller.onRender(); + controller.destroy(); + + controller.onRender(); + window.dispatchEvent(new Event('scroll')); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + controller.destroy(); + }); + it('does not stack duplicate window listeners across renders', () => { const add = jest.spyOn(window, 'addEventListener'); const controller = createController({ fullHeight: true }); @@ -573,6 +811,8 @@ describe('FullHeightController', () => { const scrollHandlers = add.mock.calls .filter(([eventType]) => eventType === 'scroll') .map(([, handler]) => handler); + // The same reference is re-added, so the browser keeps a + // single listener. expect(scrollHandlers).toHaveLength(2); expect(scrollHandlers[0]).toBe(scrollHandlers[1]); @@ -583,22 +823,13 @@ describe('FullHeightController', () => { }); it('observes the scrollable ancestors when container lazy loading is on', () => { - const observe = jest.fn(); - const disconnect = jest.fn(); - (window as any).ResizeObserver = jest.fn(() => ({ observe, disconnect })); - - const scrollContainer = document.createElement('div'); - scrollContainer.style.overflow = 'auto'; - const addContainerListener = jest.spyOn(scrollContainer, 'addEventListener'); - const removeContainerListener = jest.spyOn(scrollContainer, 'removeEventListener'); - - const controller = createController({ + const { + controller, addContainerListener, removeContainerListener, observe, disconnect, + } = mountInScrollContainer({ fullHeight: true, lazyLoadingForFullHeight: true, enableScrollableContainerLazyLoading: true, }); - scrollContainer.appendChild(iFrame); - document.body.appendChild(scrollContainer); controller.onRender(); expect(addContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); @@ -610,16 +841,7 @@ describe('FullHeightController', () => { }); it('pushes the visible coordinates on a container scroll', () => { - (window as any).ResizeObserver = jest.fn(() => ({ - observe: jest.fn(), - disconnect: jest.fn(), - })); - const scrollContainer = document.createElement('div'); - scrollContainer.style.overflow = 'auto'; - - const controller = createController({ fullHeight: true }); - scrollContainer.appendChild(iFrame); - document.body.appendChild(scrollContainer); + const { controller, scrollContainer } = mountInScrollContainer({ fullHeight: true }); controller.onRender(); scrollContainer.dispatchEvent(new Event('scroll')); @@ -635,20 +857,10 @@ describe('FullHeightController', () => { }); it('pushes the visible coordinates when an observed container resizes', () => { - let resizeCallback: () => void; - (window as any).ResizeObserver = jest.fn((callback) => { - resizeCallback = callback; - return { observe: jest.fn(), disconnect: jest.fn() }; - }); - const scrollContainer = document.createElement('div'); - scrollContainer.style.overflow = 'auto'; - - const controller = createController({ fullHeight: true }); - scrollContainer.appendChild(iFrame); - document.body.appendChild(scrollContainer); + const { controller, fireResizeObserver } = mountInScrollContainer({ fullHeight: true }); controller.onRender(); - resizeCallback(); + fireResizeObserver(); expect(host.trigger).toHaveBeenCalledWith( HostEvent.VisibleEmbedCoordinates, expect.objectContaining({ top: expect.any(Number) }), @@ -670,55 +882,39 @@ describe('FullHeightController', () => { }); it('observes each resize target only once', () => { - const observe = jest.fn(); - (window as any).ResizeObserver = jest.fn(() => ({ - observe, - disconnect: jest.fn(), - })); - const container = document.createElement('div'); - container.style.overflow = 'auto'; - - const controller = createController({ fullHeight: true }); - container.appendChild(iFrame); - document.body.appendChild(container); + const { controller, scrollContainer, observe } = mountInScrollContainer({ + fullHeight: true, + }); + // The parent is also the clipping ancestor, so it must not + // be observed twice. jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(-100, 400)); - jest.spyOn(container, 'getBoundingClientRect').mockReturnValue(rectOf(50, 300)); + jest.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(rectOf(50, 300)); controller.onRender(); expect(observe).toHaveBeenCalledTimes(1); - expect(observe).toHaveBeenCalledWith(container); + expect(observe).toHaveBeenCalledWith(scrollContainer); controller.destroy(); }); it('does not touch the containers when container lazy loading is off', () => { - const resizeObserver = jest.fn(); - (window as any).ResizeObserver = resizeObserver; - const scrollContainer = document.createElement('div'); - scrollContainer.style.overflow = 'auto'; - const addContainerListener = jest.spyOn(scrollContainer, 'addEventListener'); - - const controller = createController({ - fullHeight: true, - lazyLoadingForFullHeight: true, - enableScrollableContainerLazyLoading: false, - }); - scrollContainer.appendChild(iFrame); - document.body.appendChild(scrollContainer); + const { controller, addContainerListener, resizeObserverCtor } = mountInScrollContainer( + { + fullHeight: true, + lazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: false, + }, + ); controller.onRender(); expect(addContainerListener).not.toHaveBeenCalled(); - expect(resizeObserver).not.toHaveBeenCalled(); + expect(resizeObserverCtor).not.toHaveBeenCalled(); }); it('still tracks the containers in an environment without ResizeObserver', () => { - delete (window as any).ResizeObserver; - const scrollContainer = document.createElement('div'); - scrollContainer.style.overflow = 'auto'; - const addContainerListener = jest.spyOn(scrollContainer, 'addEventListener'); - - const controller = createController({ fullHeight: true }); - scrollContainer.appendChild(iFrame); - document.body.appendChild(scrollContainer); + const { controller, addContainerListener } = mountInScrollContainer( + { fullHeight: true }, + { withResizeObserver: false }, + ); expect(() => controller.onRender()).not.toThrow(); expect(addContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); @@ -726,18 +922,9 @@ describe('FullHeightController', () => { }); it('drops the previous containers when the embed re-renders', () => { - const disconnect = jest.fn(); - (window as any).ResizeObserver = jest.fn(() => ({ - observe: jest.fn(), - disconnect, - })); - const scrollContainer = document.createElement('div'); - scrollContainer.style.overflow = 'auto'; - const removeContainerListener = jest.spyOn(scrollContainer, 'removeEventListener'); - - const controller = createController({ fullHeight: true }); - scrollContainer.appendChild(iFrame); - document.body.appendChild(scrollContainer); + const { + controller, removeContainerListener, disconnect, + } = mountInScrollContainer({ fullHeight: true }); controller.onRender(); controller.onRender(); @@ -747,11 +934,7 @@ describe('FullHeightController', () => { }); it('is safe to destroy more than once', () => { - (window as any).ResizeObserver = jest.fn(() => ({ - observe: jest.fn(), - disconnect: jest.fn(), - })); - const controller = createController({ fullHeight: true }); + const { controller } = mountInScrollContainer({ fullHeight: true }); controller.onRender(); controller.destroy(); expect(() => controller.destroy()).not.toThrow(); diff --git a/src/full-height.ts b/src/full-height.ts index c25a90b1..334dd95c 100644 --- a/src/full-height.ts +++ b/src/full-height.ts @@ -89,13 +89,16 @@ export class FullHeightController { private readonly viewConfig: FullHeightViewConfig & Pick, private readonly host: FullHeightEmbedHost, ) { + this.viewConfig = { ...viewConfig }; this.applyLazyLoadingDefaults(); } /** * Turns lazy loading on for a full-height embed unless the host app has - * opted out. Mutates the view config in place so that everything reading it - * later — query params, listeners, visibility maths — sees the same values. + * opted out. The defaults land on the controller's own copy of the view + * config, so everything reading it later — query params, listeners, + * visibility math — sees the same values, and the object the host app + * passed in is left untouched. */ private applyLazyLoadingDefaults(): void { if (!this.isEnabled) { @@ -252,7 +255,10 @@ export class FullHeightController { if (!this.isLazyLoadEnabled) { return; } - this.host.trigger(HostEvent.VisibleEmbedCoordinates, this.getVisibleCoordinates()); + const coordinates = this.getVisibleCoordinates(); + if (coordinates) { + this.host.trigger(HostEvent.VisibleEmbedCoordinates, coordinates); + } }; private getVisibleCoordinates() { diff --git a/src/types.ts b/src/types.ts index 461ae8aa..712a14dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10032,7 +10032,7 @@ export interface VisualizationOverrides { } /** - * The configuration object for the full-height behaviour shared by the + * The configuration object for the full-height behavior shared by the * Liveboard and app embeds. * * When `fullHeight` is enabled the SDK resizes the embed container to match the diff --git a/src/utils.spec.ts b/src/utils.spec.ts index e6dc2afc..a62b2887 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -1353,7 +1353,7 @@ describe('calculateElementCenter', () => { expect(result.iframeScrolled).toBe(0); }); - test('tracks the centre as the page scrolls into the element', () => { + test('tracks the center as the page scrolls into the element', () => { setViewport(500, 600); // Scrolled 500px into a 2000px element that starts at the page top. const result = calculateElementCenter(mockElement(0, 2000)); From 55f40764c8cbfc588e0b249911ad9958f832bebc Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Wed, 2 Sep 2026 11:55:02 +0530 Subject: [PATCH 6/8] SCAL-334772 transfer the flag change to app level --- src/embed/app.ts | 3 +- src/embed/liveboard.ts | 3 +- src/full-height.spec.ts | 78 +++++++++++++++++++++++++++++++++-------- src/full-height.ts | 51 +++++++++++++-------------- 4 files changed, 93 insertions(+), 42 deletions(-) diff --git a/src/embed/app.ts b/src/embed/app.ts index 4feb603e..85fd2b7e 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -21,7 +21,7 @@ import { VisualizationOverrides, SpotterFileUploadFileTypes, } from '../types'; -import { FullHeightController } from '../full-height'; +import { FullHeightController, resolveLazyLoadingDefaults } from '../full-height'; import { V1Embed } from './ts-embed'; import { SpotterChatViewConfig, SpotterSidebarViewConfig, SpotterQueryMode, SpotterShareConversationConfig, StarterPromptsConfig } from './conversation'; import { buildSpotterSidebarAppInitData, buildSpotterShareConversationAppInitData, buildStarterPromptsAppInitData } from './spotter-utils'; @@ -933,6 +933,7 @@ export class AppEmbed extends V1Embed { viewConfig.embedComponentType = 'AppEmbed'; super(domSelector, viewConfig); if (this.viewConfig.fullHeight === true) { + Object.assign(this.viewConfig, resolveLazyLoadingDefaults(this.viewConfig)); this.fullHeightController = new FullHeightController(this.viewConfig, { getIframe: () => this.iFrame, setFrameHeight: (height) => this.setIFrameHeight(height), diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index ca198e71..54a58805 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -26,7 +26,7 @@ import { ContextType, DefaultAppInitData, } from '../types'; -import { FullHeightController } from '../full-height'; +import { FullHeightController, resolveLazyLoadingDefaults } from '../full-height'; import { getQueryParamString, isUndefined, setParamIfDefined } from '../utils'; import { getAuthPromise } from './base'; import { TsEmbed, V1Embed } from './ts-embed'; @@ -568,6 +568,7 @@ export class LiveboardEmbed extends V1Embed { logger.warn('Full height is currently only supported for Liveboard embeds.' + 'Using full height with vizId might lead to unexpected behavior.'); } + Object.assign(this.viewConfig, resolveLazyLoadingDefaults(this.viewConfig)); this.fullHeightController = new FullHeightController(this.viewConfig, { getIframe: () => this.iFrame, setFrameHeight: (height) => this.setIFrameHeight(height), diff --git a/src/full-height.spec.ts b/src/full-height.spec.ts index 612511d2..32ccd33f 100644 --- a/src/full-height.spec.ts +++ b/src/full-height.spec.ts @@ -1,4 +1,6 @@ -import { FullHeightController, FullHeightEmbedHost } from './full-height'; +import { + FullHeightController, FullHeightEmbedHost, resolveLazyLoadingDefaults, +} from './full-height'; import { BaseViewConfig, EmbedEvent, FullHeightViewConfig, HostEvent, MessageCallback, Param, } from './types'; @@ -7,6 +9,42 @@ type ControllerConfig = FullHeightViewConfig & Pick { + it('turns every lazy-loading setting on when the host app set none', () => { + expect(resolveLazyLoadingDefaults({})).toEqual({ + lazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: true, + lazyLoadingMargin: DEFAULT_LAZY_LOADING_MARGIN, + }); + }); + + it('preserves an explicit opt-out', () => { + expect(resolveLazyLoadingDefaults({ + lazyLoadingForFullHeight: false, + enableScrollableContainerLazyLoading: false, + lazyLoadingMargin: '0px', + })).toEqual({ + lazyLoadingForFullHeight: false, + enableScrollableContainerLazyLoading: false, + lazyLoadingMargin: '0px', + }); + }); + + it('defaults only what the host app left unset', () => { + expect(resolveLazyLoadingDefaults({ lazyLoadingMargin: '50px' })).toEqual({ + lazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: true, + lazyLoadingMargin: '50px', + }); + }); + + it('does not write to the config it is given', () => { + const viewConfig: FullHeightViewConfig = { fullHeight: true }; + resolveLazyLoadingDefaults(viewConfig); + expect(viewConfig).toEqual({ fullHeight: true }); + }); +}); + describe('FullHeightController', () => { let iFrame: HTMLIFrameElement; let host: FullHeightEmbedHost; @@ -35,6 +73,17 @@ describe('FullHeightController', () => { Object.defineProperty(window, 'innerWidth', { value: innerWidth, configurable: true }); }; + /** + * Mirrors what the embed does in its constructor: resolve the lazy-loading + * defaults and hand the controller the result. Returns a new object so the + * caller's config is left alone, letting tests assert on both. + */ + const withDefaults = (viewConfig: ControllerConfig): ControllerConfig => ( + viewConfig.fullHeight === true + ? { ...viewConfig, ...resolveLazyLoadingDefaults(viewConfig) } + : viewConfig + ); + const createControllerFor = (viewConfig: ControllerConfig) => createController(viewConfig); const createController = ( @@ -53,7 +102,7 @@ describe('FullHeightController', () => { }, trigger: jest.fn(), }; - const controller = new FullHeightController(viewConfig, host); + const controller = new FullHeightController(withDefaults(viewConfig), host); controller.registerEventHandlers(); return controller; }; @@ -168,10 +217,20 @@ describe('FullHeightController', () => { }); it('leaves the view config the host app passed in untouched', () => { - // The controller defaults its own copy, so the host app's object - // never grows keys it did not set. + // Defaulting happens in the embed; the controller never writes to + // the config it is handed. Built without `withDefaults` so the + // controller really does receive the caller's own object. const viewConfig: ControllerConfig = { fullHeight: true }; - createControllerFor(viewConfig); + const controller = new FullHeightController(viewConfig, { + getIframe: () => iFrame, + setFrameHeight: jest.fn(), + on: jest.fn(), + trigger: jest.fn(), + }); + controller.registerEventHandlers(); + controller.addQueryParams({}); + controller.onRender(); + controller.destroy(); expect(viewConfig).toEqual({ fullHeight: true }); }); @@ -220,15 +279,6 @@ describe('FullHeightController', () => { controller.onRender(); expect(add).not.toHaveBeenCalled(); }); - - it('ignores changes the host app makes to its config after construction', () => { - const viewConfig: ControllerConfig = { fullHeight: true, minimumHeight: 800 }; - const controller = createControllerFor(viewConfig); - viewConfig.minimumHeight = 900; - viewConfig.fullHeight = false; - expect(controller.minimumHeight).toBe(800); - expect(queryParamsFor(viewConfig)[Param.fullHeight]).toBeUndefined(); - }); }); describe('minimumHeight', () => { diff --git a/src/full-height.ts b/src/full-height.ts index 334dd95c..f06ed2fa 100644 --- a/src/full-height.ts +++ b/src/full-height.ts @@ -48,6 +48,30 @@ const LIVEBOARD_RELATED_ROUTES = [ '/import-tsl/PINBOARD_ANSWER_BOOK/', ]; +/** + * The lazy-loading settings a full-height embed falls back to when the host app + * has not chosen its own. + * + * Pure by design: the embed owns its view config, so the embed applies the + * result itself rather than having this module write to it. + * @param viewConfig The embed's view config + * @returns The settings to apply, with the host app's own choices preserved + */ +export const resolveLazyLoadingDefaults = (viewConfig: FullHeightViewConfig) => ({ + lazyLoadingForFullHeight: + viewConfig.lazyLoadingForFullHeight === undefined + ? true + : viewConfig.lazyLoadingForFullHeight, + enableScrollableContainerLazyLoading: + viewConfig.enableScrollableContainerLazyLoading === undefined + ? true + : viewConfig.enableScrollableContainerLazyLoading, + lazyLoadingMargin: + viewConfig.lazyLoadingMargin === undefined + ? DEFAULT_LAZY_LOADING_MARGIN + : viewConfig.lazyLoadingMargin, +}); + /** * The subset of the embed the full-height controller drives. Keeping this * narrow lets the controller stay independent of the embed class hierarchy. @@ -88,32 +112,7 @@ export class FullHeightController { constructor( private readonly viewConfig: FullHeightViewConfig & Pick, private readonly host: FullHeightEmbedHost, - ) { - this.viewConfig = { ...viewConfig }; - this.applyLazyLoadingDefaults(); - } - - /** - * Turns lazy loading on for a full-height embed unless the host app has - * opted out. The defaults land on the controller's own copy of the view - * config, so everything reading it later — query params, listeners, - * visibility math — sees the same values, and the object the host app - * passed in is left untouched. - */ - private applyLazyLoadingDefaults(): void { - if (!this.isEnabled) { - return; - } - if (this.viewConfig.lazyLoadingForFullHeight === undefined) { - this.viewConfig.lazyLoadingForFullHeight = true; - } - if (this.viewConfig.enableScrollableContainerLazyLoading === undefined) { - this.viewConfig.enableScrollableContainerLazyLoading = true; - } - if (this.viewConfig.lazyLoadingMargin === undefined) { - this.viewConfig.lazyLoadingMargin = DEFAULT_LAZY_LOADING_MARGIN; - } - } + ) {} /** * Whether the host app asked for a full-height embed. From 9f86e09f32f4ee2c5468a7254f0bf5d361634e0f Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Mon, 31 Aug 2026 16:51:09 +0530 Subject: [PATCH 7/8] SCAL-335430-POC Send embed config over postMessage instead of the iframe URL --- src/embed/app.ts | 2 +- src/embed/auto-frame-renderer.ts | 2 +- src/embed/bodyless-conversation.ts | 2 +- src/embed/conversation.ts | 2 +- src/embed/liveboard.ts | 2 +- src/embed/search-bar.tsx | 7 +- src/embed/search.spec.ts | 15 +++ src/embed/search.ts | 2 +- src/embed/ts-embed.spec.ts | 146 +++++++++++++++++++++++++++++ src/embed/ts-embed.ts | 121 +++++++++++++++++++++--- src/types.ts | 38 ++++++++ 11 files changed, 317 insertions(+), 22 deletions(-) diff --git a/src/embed/app.ts b/src/embed/app.ts index 85fd2b7e..37ec02f1 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -977,7 +977,7 @@ export class AppEmbed extends V1Embed { * embedded Liveboard or visualization. */ protected getEmbedParams() { - const params = this.getEmbedParamsObject(); + const params = this.getUrlQueryParamsObject(); return getQueryParamString(params, true); } diff --git a/src/embed/auto-frame-renderer.ts b/src/embed/auto-frame-renderer.ts index e0730a58..e6506d4c 100644 --- a/src/embed/auto-frame-renderer.ts +++ b/src/embed/auto-frame-renderer.ts @@ -108,7 +108,7 @@ class AutoFrameRenderer extends TsEmbed { * @returns The constructed URL to use for the ThoughtSpot embed iframe. */ private getMCPIframeSrc(sourceSrc: string) { - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); const sourceURL = new URL(sourceSrc); const existingQueryParams = sourceURL.searchParams; const existingQueryParamsObject = Object.fromEntries(existingQueryParams); diff --git a/src/embed/bodyless-conversation.ts b/src/embed/bodyless-conversation.ts index 3af6480a..cecfa11f 100644 --- a/src/embed/bodyless-conversation.ts +++ b/src/embed/bodyless-conversation.ts @@ -56,7 +56,7 @@ export class ConversationMessage extends TsEmbed { messageId, } = this.viewConfig; const path = 'conv-assist-answer'; - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); let query = ''; const queryParamsString = getQueryParamString(queryParams, true); diff --git a/src/embed/conversation.ts b/src/embed/conversation.ts index c280db14..e890e71e 100644 --- a/src/embed/conversation.ts +++ b/src/embed/conversation.ts @@ -931,7 +931,7 @@ export class SpotterEmbed extends TsEmbed { const path = sharedConversationId ? `insights/conv-assist/s/${encodeURIComponent(sharedConversationId)}` : 'insights/conv-assist'; - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); let query = ''; const queryParamsString = getQueryParamString(queryParams, true); diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index 54a58805..1c7b10b3 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -594,7 +594,7 @@ export class LiveboardEmbed extends V1Embed { * embedded Liveboard or visualization. */ protected getEmbedParams() { - const params = this.getEmbedParamsObject(); + const params = this.getUrlQueryParamsObject(); const queryParams = getQueryParamString(params, true); return queryParams; } diff --git a/src/embed/search-bar.tsx b/src/embed/search-bar.tsx index 160d87f5..3607aeef 100644 --- a/src/embed/search-bar.tsx +++ b/src/embed/search-bar.tsx @@ -114,7 +114,10 @@ export class SearchBarEmbed extends TsEmbed { protected embedComponentType = 'SearchBarEmbed'; constructor(domSelector: string, viewConfig: SearchBarViewConfig) { - super(domSelector); + // The view config is passed up so the base constructor can read the + // flags it needs while it wires up the embed, then re-assigned here to + // keep this class's existing view config semantics. + super(domSelector, viewConfig); this.viewConfig = viewConfig; } @@ -163,7 +166,7 @@ export class SearchBarEmbed extends TsEmbed { * @param dataSources A list of data source GUIDs */ private getIFrameSrc() { - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); const path = 'search-bar-embed'; let query = ''; diff --git a/src/embed/search.spec.ts b/src/embed/search.spec.ts index f855cd0f..d8654edc 100644 --- a/src/embed/search.spec.ts +++ b/src/embed/search.spec.ts @@ -774,6 +774,21 @@ test('should pass forceTable parameter when forceTable is true', async () => { }); describe('SearchBarEmbed tests', () => { + test('should keep data sources off the URL when sendConfigAsPostMessage is set', async () => { + const searchBarEmbed = new SearchBarEmbed(getRootEl() as any, { + ...defaultViewConfig, + dataSources: ['source-1', 'source-2'], + sendConfigAsPostMessage: true, + } as any); + searchBarEmbed.render(); + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).toContain('hostAppUrl='); + expect(iframeSrc).not.toContain('dataSources'); + expect(iframeSrc).not.toContain('source-1'); + }); + }); + test('should pass dataSources parameter when dataSources array is provided', async () => { const searchBarEmbed = new SearchBarEmbed(getRootEl() as any, { ...defaultViewConfig, diff --git a/src/embed/search.ts b/src/embed/search.ts index 680b42bb..7a2a06e2 100644 --- a/src/embed/search.ts +++ b/src/embed/search.ts @@ -526,7 +526,7 @@ export class SearchEmbed extends TsEmbed { excludeRuntimeParametersfromURL, excludeRuntimeFiltersfromURL, } = this.viewConfig; - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); let query = ''; const queryParamsString = getQueryParamString(queryParams, true); if (queryParamsString) { diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 7a7e7acb..dd54a224 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -6243,3 +6243,149 @@ describe('ShowPreRender with UpdateEmbedParams', () => { }); }); }); + +describe('sendConfigAsPostMessage', () => { + const lbConfig = { + liveboardId, + hiddenActions: [Action.Download], + additionalFlags: { internalBlinkFlag: true }, + liveboardV2: true, + }; + + const renderAndGetSrc = async (viewConfig: any) => { + const embed = new LiveboardEmbed(getRootEl(), { ...defaultViewConfig, ...viewConfig }); + await embed.render(); + await waitFor(() => !!getIFrameEl()); + return { embed, src: getIFrameSrc() }; + }; + + const signalFrameReady = () => { + postMessageToParent(getIFrameEl().contentWindow, { + type: EmbedEvent.EmbedListenerReady, + }); + }; + + beforeEach(() => { + document.body.innerHTML = getDocumentBody(); + mockProcessTrigger.mockReset(); + mockProcessTrigger.mockResolvedValue({}); + init({ + thoughtSpotHost, + authType: AuthType.None, + }); + }); + + test('leaves the URL untouched when the flag is not set', async () => { + const { src } = await renderAndGetSrc(lbConfig); + + expect(src).toContain('hideAction='); + expect(src).toContain('internalBlinkFlag=true'); + expect(src).toContain('isPinboardV2Enabled=true'); + }); + + test('keeps only the bootstrap params on the URL when the flag is set', async () => { + const { src } = await renderAndGetSrc({ ...lbConfig, sendConfigAsPostMessage: true }); + + // Boot and auth handshake stays on the URL. + expect(src).toContain('hostAppUrl='); + expect(src).toContain(`authType=${AuthType.None}`); + expect(src).toContain(`sdkVersion=${version}`); + expect(src).toContain('blockNonEmbedFullAppAccess=true'); + expect(src).toContain('viewPortHeight='); + + // View configuration and internal flags do not. + expect(src).not.toContain('hideAction='); + expect(src).not.toContain('internalBlinkFlag'); + expect(src).not.toContain('isPinboardV2Enabled'); + expect(src).not.toContain('enableDataPanelV2'); + }); + + test('keeps the deep-link route on the URL when the flag is set', async () => { + const { src } = await renderAndGetSrc({ ...lbConfig, sendConfigAsPostMessage: true }); + + expect(src).toContain(`/embed/viz/${liveboardId}`); + }); + + test('sends the full config over UpdateEmbedParams once the frame is ready', async () => { + await renderAndGetSrc({ ...lbConfig, sendConfigAsPostMessage: true }); + + signalFrameReady(); + + await executeAfterWait(() => { + expect(mockProcessTrigger).toHaveBeenCalledWith( + expect.any(Object), + HostEvent.UpdateEmbedParams, + expect.any(String), + expect.objectContaining({ + liveboardId, + hiddenActions: [Action.Download], + internalBlinkFlag: true, + [Param.HideActions]: expect.arrayContaining([Action.Download]), + }), + undefined, + ); + }); + }); + + test('sends UpdateEmbedParams exactly once for a plain embed', async () => { + await renderAndGetSrc({ ...lbConfig, sendConfigAsPostMessage: true }); + + signalFrameReady(); + signalFrameReady(); + + await executeAfterWait(() => { + const updateCalls = mockProcessTrigger.mock.calls.filter( + (call) => call[1] === HostEvent.UpdateEmbedParams, + ); + expect(updateCalls).toHaveLength(1); + }); + }); + + test('does not send UpdateEmbedParams when the flag is not set', async () => { + await renderAndGetSrc(lbConfig); + + signalFrameReady(); + + await executeAfterWait(() => { + const updateCalls = mockProcessTrigger.mock.calls.filter( + (call) => call[1] === HostEvent.UpdateEmbedParams, + ); + expect(updateCalls).toHaveLength(0); + }); + }); + + test('does not double-send for a pre-rendered embed that is shown', async () => { + mockMessageChannel(); + (window as any).ResizeObserver = + window.ResizeObserver || + jest.fn().mockImplementation(() => ({ + disconnect: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), + })); + + const embed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + ...lbConfig, + preRenderId: 'send-config-post-message', + sendConfigAsPostMessage: true, + }); + await embed.preRender(); + await waitFor(() => !!getIFrameEl()); + + signalFrameReady(); + await executeAfterWait(() => { + expect(embed.isEmbedContainerLoaded).toBe(true); + }); + + mockProcessTrigger.mockClear(); + await embed.showPreRender(); + + await executeAfterWait(() => { + const updateCalls = mockProcessTrigger.mock.calls.filter( + (call) => call[1] === HostEvent.UpdateEmbedParams, + ); + expect(updateCalls).toHaveLength(1); + }); + }); +}); diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 9831c567..dc599336 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -109,6 +109,40 @@ const TS_EMBED_ID = '_thoughtspot-embed'; const PRERENDER_CONTAINER_ORIGINAL_POSITION_KEY = 'tsEmbedOriginalPosition'; const PRERENDER_WRAPPER_ID_PREFIX = 'tsEmbed-pre-render-wrapper-'; +/** + * Query parameters that stay on the iframe `src` when the embed sets + * `sendConfigAsPostMessage`. These are the parameters the application shell + * needs before it can receive a postMessage at all: the embed marker, the host + * application URL used to validate the message origin, the SDK version, the + * flags that pick the authentication flow, and the boot-time settings that + * would otherwise be applied a frame late (viewport, log level, locale, + * formatting and org). Everything else is delivered over + * `HostEvent.UpdateEmbedParams`. + * @internal + */ +const BOOTSTRAP_URL_PARAMS: ReadonlySet = new Set([ + Param.EmbedApp, + Param.HostAppUrl, + Param.Version, + Param.AuthType, + Param.AutoLogin, + Param.DisableLoginRedirect, + Param.ForceSAMLAutoRedirect, + Param.cookieless, + Param.preAuthCache, + Param.blockNonEmbedFullAppAccess, + Param.OverrideOrgId, + Param.ViewPortHeight, + Param.ViewPortWidth, + Param.ClientLogLevel, + Param.OverrideNativeConsole, + Param.PendoTrackingKey, + Param.NumberFormatLocale, + Param.DateFormatLocale, + Param.CurrencyFormat, + Param.Locale, +]); + /** * The event id map from v2 event names to v1 event id * v1 events are the classic embed events implemented in Blink v1 @@ -206,6 +240,20 @@ export class TsEmbed { */ private shouldEncodeUrlQueryParams = false; + /** + * Should the embed configuration be delivered over postMessage + * (`HostEvent.UpdateEmbedParams`) once the frame is ready, leaving only the + * bootstrap parameters on the iframe `src`. + * @default false + */ + private sendConfigAsPostMessage = false; + + /** + * Guards the initial-load configuration send so it happens once per embed, + * even if the container-ready callbacks are flushed more than once. + */ + private hasSentInitialEmbedParams = false; + private defaultHiddenActions = [Action.ReportError]; private resizeObserver: ResizeObserver; @@ -233,6 +281,7 @@ export class TsEmbed { excludeRuntimeParametersfromURL: true, ...viewConfig, }; + this.sendConfigAsPostMessage = this.viewConfig.sendConfigAsPostMessage ?? false; this.registerAppInit(); uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_EMBED_CREATE, { ...viewConfig, @@ -691,6 +740,13 @@ export class TsEmbed { const authInitHandler = this.createEmbedContainerHandler(EmbedEvent.AuthInit); this.on(EmbedEvent.AuthInit, authInitHandler, { start: false }, true); this.on(EmbedEvent.RefreshAuthToken, this.tokenRefresh, { start: false }, true); + if (this.sendConfigAsPostMessage && !this.isPreRenderEmbed()) { + this.executeAfterEmbedContainerLoaded(() => { + if (this.hasSentInitialEmbedParams) return; + this.hasSentInitialEmbedParams = true; + this.sendEmbedParamsOverPostMessage(); + }); + } }; /** @@ -920,7 +976,7 @@ export class TsEmbed { } protected getEmbedParams() { - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); return getQueryParamString(queryParams); } @@ -929,6 +985,27 @@ export class TsEmbed { return params; } + /** + * The parameters that go on the iframe `src`. + * + * This is the full parameter set, unless the embed sets + * `sendConfigAsPostMessage`, in which case only the bootstrap parameters are + * kept and the rest is delivered over `HostEvent.UpdateEmbedParams` once the + * frame is ready. Every URL builder must go through this method; + * `getEmbedParamsObject()` stays the full set because it also feeds the + * postMessage payload. + * @returns The parameters to encode into the iframe `src`. + */ + protected getUrlQueryParamsObject(): Record { + const queryParams = this.getEmbedParamsObject(); + if (!this.sendConfigAsPostMessage) { + return queryParams; + } + return Object.fromEntries( + Object.entries(queryParams).filter(([key]) => BOOTSTRAP_URL_PARAMS.has(key)), + ); + } + protected getRootIframeSrc() { const query = this.getEmbedParams(); return this.getEmbedBasePath(query); @@ -1979,24 +2056,40 @@ export class TsEmbed { return this.renderIFrame(prerenderFrameSrc); } + /** + * Sends the full embed configuration to the embedded app over + * `HostEvent.UpdateEmbedParams`. + * + * Used by the pre-render show path, and by the initial load when the embed + * sets `sendConfigAsPostMessage`. The caller is expected to have waited for + * the embed container to be ready. + */ + protected async sendEmbedParamsOverPostMessage(): Promise { + try { + const params = await this.getUpdateEmbedParamsObject(); + await this.trigger(HostEvent.UpdateEmbedParams, params); + } catch (error) { + logger.error(ERROR_MESSAGE.UPDATE_PARAMS_FAILED, error); + this.handleError({ + errorType: ErrorDetailsTypes.API, + message: error?.message || ERROR_MESSAGE.UPDATE_PARAMS_FAILED, + code: EmbedErrorCodes.UPDATE_PARAMS_FAILED, + error: error?.message || error, + }); + } + } + protected beforePrerenderVisible(): void { // We can ignore this as its a bit expensive and the newer customers // have moved on to UpdateEmbedParams supported clusters // this.validatePreRenderViewConfig(this.viewConfig); removed in #517 logger.debug('triggering UpdateEmbedParams', this.viewConfig); - this.executeAfterEmbedContainerLoaded(async () => { - try { - const params = await this.getUpdateEmbedParamsObject(); - this.trigger(HostEvent.UpdateEmbedParams, params); - } catch (error) { - logger.error(ERROR_MESSAGE.UPDATE_PARAMS_FAILED, error); - this.handleError({ - errorType: ErrorDetailsTypes.API, - message: error?.message || ERROR_MESSAGE.UPDATE_PARAMS_FAILED, - code: EmbedErrorCodes.UPDATE_PARAMS_FAILED, - error: error?.message || error, - }); - } + this.executeAfterEmbedContainerLoaded(() => { + // The pre-render path owns configuration delivery for this embed, + // so mark the initial send as done to keep the two paths from + // pushing the same payload back to back. + this.hasSentInitialEmbedParams = true; + this.sendEmbedParamsOverPostMessage(); }); } diff --git a/src/types.ts b/src/types.ts index 712a14dc..f0c23826 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1669,6 +1669,44 @@ export interface BaseViewConfig extends ApiInterceptFlags { * ``` */ useHostEventsV2?: boolean; + + /** + * Send the embed configuration to the ThoughtSpot application over + * postMessage (`HostEvent.UpdateEmbedParams`) as soon as the iframe signals + * that it is ready, instead of encoding it into the iframe `src`. + * + * Only the parameters needed to boot and authenticate the frame are kept on + * the URL: the embed marker, the host application URL, the SDK version, the + * authentication flags, the viewport size, the log level, the locale and + * formatting options, and the org override. Everything else - hidden and + * visible actions, hidden and visible tabs, data sources, customizations + * and `additionalFlags` - travels over postMessage. + * + * Use this to keep configuration out of the DOM and to keep the iframe URL + * short. Note two consequences before you turn it on: + * - The application briefly renders before the configuration arrives, so a + * flag that changes the initial layout can be applied a frame late. + * - The cluster must support `HostEvent.UpdateEmbedParams`. On a cluster + * that ignores it, the embed renders with default configuration. + * + * Runtime filters and parameters are not affected by this flag. They are + * already kept off the URL by `excludeRuntimeFiltersfromURL` and + * `excludeRuntimeParametersfromURL`, which both default to `true`. + * + * For a pre-rendered embed, the configuration is delivered by the existing + * pre-render path when `showPreRender()` is called, so the pre-rendered + * frame stays unconfigured while it warms up in the background. + * @default false + * @version SDK: 1.51.0 | ThoughtSpot Cloud: 26.8.0.cl + * @example + * ```js + * const embed = new LiveboardEmbed('#tsEmbed', { + * ... // other embed view config + * sendConfigAsPostMessage: true, + * }); + * ``` + */ + sendConfigAsPostMessage?: boolean; } /** From 59f7fa25c52d23771e79d0793a274576c3282392 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Wed, 2 Sep 2026 14:44:03 +0530 Subject: [PATCH 8/8] SCAL-335430-POC test --- src/embed/ts-embed.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index dc599336..1ed8235c 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -248,12 +248,6 @@ export class TsEmbed { */ private sendConfigAsPostMessage = false; - /** - * Guards the initial-load configuration send so it happens once per embed, - * even if the container-ready callbacks are flushed more than once. - */ - private hasSentInitialEmbedParams = false; - private defaultHiddenActions = [Action.ReportError]; private resizeObserver: ResizeObserver; @@ -740,10 +734,8 @@ export class TsEmbed { const authInitHandler = this.createEmbedContainerHandler(EmbedEvent.AuthInit); this.on(EmbedEvent.AuthInit, authInitHandler, { start: false }, true); this.on(EmbedEvent.RefreshAuthToken, this.tokenRefresh, { start: false }, true); - if (this.sendConfigAsPostMessage && !this.isPreRenderEmbed()) { + if (this.sendConfigAsPostMessage) { this.executeAfterEmbedContainerLoaded(() => { - if (this.hasSentInitialEmbedParams) return; - this.hasSentInitialEmbedParams = true; this.sendEmbedParamsOverPostMessage(); }); } @@ -2085,10 +2077,6 @@ export class TsEmbed { // this.validatePreRenderViewConfig(this.viewConfig); removed in #517 logger.debug('triggering UpdateEmbedParams', this.viewConfig); this.executeAfterEmbedContainerLoaded(() => { - // The pre-render path owns configuration delivery for this embed, - // so mark the initial send as done to keep the two paths from - // pushing the same payload back to back. - this.hasSentInitialEmbedParams = true; this.sendEmbedParamsOverPostMessage(); }); }