From 85f5e6ba58b0c791e5c28d6605dde9b204329aea Mon Sep 17 00:00:00 2001 From: Ruchi Anand Date: Wed, 27 May 2026 10:42:29 +0530 Subject: [PATCH 1/7] fix --- src/embed/app.spec.ts | 33 ++++++++++-- src/embed/app.ts | 29 +++++++++- src/embed/liveboard.spec.ts | 32 ++++++++++- src/embed/liveboard.ts | 29 +++++++++- src/utils.spec.ts | 104 ++++++++++++++++++++++++++++++++++++ src/utils.ts | 75 +++++++++++++++++++++++--- 6 files changed, 286 insertions(+), 16 deletions(-) diff --git a/src/embed/app.spec.ts b/src/embed/app.spec.ts index 1fdbf02cb..98d5bb13a 100644 --- a/src/embed/app.spec.ts +++ b/src/embed/app.spec.ts @@ -1720,6 +1720,35 @@ describe('App embed tests', () => { addEventListenerSpy.mockRestore(); }); + test('should listen to scroll and resize changes from scrollable iframe ancestors', async () => { + const scrollContainer = getRootEl(); + scrollContainer.style.overflow = 'auto'; + + const scrollContainerAddEventListenerSpy = jest.spyOn(scrollContainer, 'addEventListener'); + const resizeObserveSpy = jest.fn(); + const resizeDisconnectSpy = jest.fn(); + (window as any).ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: resizeObserveSpy, + disconnect: resizeDisconnectSpy, + })); + + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as AppViewConfig); + + await appEmbed.render(); + + await executeAfterWait(() => { + expect(scrollContainerAddEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(resizeObserveSpy).toHaveBeenCalledWith(scrollContainer); + }, 100); + + appEmbed.destroy(); + expect(resizeDisconnectSpy).toHaveBeenCalled(); + }); + test('should remove window event listeners on destroy when fullHeight and lazyLoadingForFullHeight are enabled', async () => { const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); @@ -1734,7 +1763,7 @@ describe('App embed tests', () => { appEmbed.destroy(); expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); - expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); removeEventListenerSpy.mockRestore(); }); @@ -1965,5 +1994,3 @@ describe('AppEmbed visualOverrides tests', () => { await testVisualOverridesInEmbed(appEmbed, visualOverrides); }); }); - - diff --git a/src/embed/app.ts b/src/embed/app.ts index 82ee7668c..a4eb4bb89 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -9,7 +9,7 @@ */ import { logger } from '../utils/logger'; -import { calculateVisibleElementData, getQueryParamString, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; +import { calculateVisibleElementData, getClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; import { Param, DOMSelector, @@ -826,6 +826,10 @@ export class AppEmbed extends V1Embed { private defaultHeight = 500; + private lazyLoadScrollContainers: HTMLElement[] = []; + + private lazyLoadResizeObserver: ResizeObserver | undefined; + constructor(domSelector: DOMSelector, viewConfig: AppViewConfig) { viewConfig.embedComponentType = 'AppEmbed'; super(domSelector, viewConfig); @@ -1295,16 +1299,37 @@ export class AppEmbed extends V1Embed { private registerLazyLoadEvents() { 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); + 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, + ...getClippingAncestors(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); + window.removeEventListener('scroll', this.sendFullHeightLazyLoadData, true); + this.lazyLoadResizeObserver?.disconnect(); + this.lazyLoadResizeObserver = undefined; + this.lazyLoadScrollContainers.forEach((scrollContainer) => { + scrollContainer.removeEventListener('scroll', this.sendFullHeightLazyLoadData); + }); + this.lazyLoadScrollContainers = []; } } diff --git a/src/embed/liveboard.spec.ts b/src/embed/liveboard.spec.ts index d434271f3..1e8737cab 100644 --- a/src/embed/liveboard.spec.ts +++ b/src/embed/liveboard.spec.ts @@ -1835,6 +1835,36 @@ describe('Liveboard/viz embed tests', () => { addEventListenerSpy.mockRestore(); }); + test('should listen to scroll and resize changes from scrollable iframe ancestors', async () => { + const scrollContainer = getRootEl(); + scrollContainer.style.overflow = 'auto'; + + const scrollContainerAddEventListenerSpy = jest.spyOn(scrollContainer, 'addEventListener'); + const resizeObserveSpy = jest.fn(); + const resizeDisconnectSpy = jest.fn(); + (window as any).ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: resizeObserveSpy, + disconnect: resizeDisconnectSpy, + })); + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as LiveboardViewConfig); + + await liveboardEmbed.render(); + + await executeAfterWait(() => { + expect(scrollContainerAddEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(resizeObserveSpy).toHaveBeenCalledWith(scrollContainer); + }, 100); + + liveboardEmbed.destroy(); + expect(resizeDisconnectSpy).toHaveBeenCalled(); + }); + test('should remove window event listeners on destroy when fullHeight and lazyLoadingForFullHeight are enabled', async () => { const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); @@ -1850,7 +1880,7 @@ describe('Liveboard/viz embed tests', () => { liveboardEmbed.destroy(); expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything()); - expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything()); + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything(), true); removeEventListenerSpy.mockRestore(); }); diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index dadd45bac..7e486fd9d 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -24,7 +24,7 @@ import { EmbedErrorCodes, ContextType, } from '../types'; -import { calculateVisibleElementData, getQueryParamString, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; +import { calculateVisibleElementData, getClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; import { getAuthPromise } from './base'; import { TsEmbed, V1Embed } from './ts-embed'; import { addPreviewStylesIfNotPresent } from '../utils/global-styles'; @@ -569,6 +569,10 @@ export class LiveboardEmbed extends V1Embed { private defaultHeight = 500; + private lazyLoadScrollContainers: HTMLElement[] = []; + + private lazyLoadResizeObserver: ResizeObserver | undefined; + constructor(domSelector: DOMSelector, viewConfig: LiveboardViewConfig) { viewConfig.embedComponentType = 'LiveboardEmbed'; @@ -1024,16 +1028,37 @@ export class LiveboardEmbed extends V1Embed { private registerLazyLoadEvents() { 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); + 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, + ...getClippingAncestors(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); + window.removeEventListener('scroll', this.sendFullHeightLazyLoadData, true); + this.lazyLoadResizeObserver?.disconnect(); + this.lazyLoadResizeObserver = undefined; + this.lazyLoadScrollContainers.forEach((scrollContainer) => { + scrollContainer.removeEventListener('scroll', this.sendFullHeightLazyLoadData); + }); + this.lazyLoadScrollContainers = []; } } diff --git a/src/utils.spec.ts b/src/utils.spec.ts index dd442eab8..4d840850c 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -18,6 +18,8 @@ import { getTypeFromValue, arrayIncludesString, calculateVisibleElementData, + getClippingAncestors, + getScrollableAncestors, formatTemplate, isValidCssMargin, resetValueFromWindow, @@ -717,6 +719,108 @@ describe('calculateVisibleElementData', () => { width: 1200, // Full viewport width }); }); + + it('should calculate data clipped by a scrollable parent', () => { + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + scrollContainer.appendChild(mockElement); + + jest.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue({ + top: 100, + left: 50, + bottom: 600, + right: 650, + width: 600, + height: 500, + } as DOMRect); + + jest.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ + top: -100, + left: 50, + bottom: 900, + right: 650, + width: 600, + height: 1000, + } as DOMRect); + + const result = calculateVisibleElementData(mockElement); + + expect(result).toEqual({ + top: 200, + height: 500, + left: 0, + width: 600, + }); + }); + + it('should calculate data clipped by a non-scroll clipping parent', () => { + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + clippingContainer.appendChild(mockElement); + + jest.spyOn(clippingContainer, 'getBoundingClientRect').mockReturnValue({ + top: 100, + left: 100, + bottom: 500, + right: 500, + width: 400, + height: 400, + } as DOMRect); + + jest.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ + top: 50, + left: 50, + bottom: 700, + right: 700, + width: 650, + height: 650, + } as DOMRect); + + const result = calculateVisibleElementData(mockElement); + + expect(result).toEqual({ + top: 50, + height: 400, + left: 50, + width: 400, + }); + }); +}); + +describe('getScrollableAncestors', () => { + it('should find scrollable ancestors inside a shadow root', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + + const shadow = host.attachShadow({ mode: 'open' }); + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + const embedTarget = document.createElement('div'); + const iframe = document.createElement('iframe'); + + shadow.appendChild(scrollContainer); + scrollContainer.appendChild(embedTarget); + embedTarget.appendChild(iframe); + + expect(getScrollableAncestors(iframe)).toEqual([scrollContainer]); + + host.remove(); + }); +}); + +describe('getClippingAncestors', () => { + it('should include scrollable and non-scroll clipping ancestors', () => { + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + const iframe = document.createElement('iframe'); + + scrollContainer.appendChild(clippingContainer); + clippingContainer.appendChild(iframe); + + expect(getClippingAncestors(iframe)).toEqual([clippingContainer, scrollContainer]); + }); }); describe('formatTemplate', () => { diff --git a/src/utils.ts b/src/utils.ts index eb12ebb30..9e4321610 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -518,22 +518,81 @@ export const handleExitPresentMode = async (): Promise => { logger.warn('Exit fullscreen API is not supported by this browser.'); }; +const scrollableOverflowPattern = /(auto|scroll|overlay)/; +const clippingOverflowPattern = /(auto|scroll|overlay|hidden|clip)/; + +const getParentElementAcrossShadowRoot = (element: HTMLElement): HTMLElement | null => { + if (element.parentElement) { + return element.parentElement; + } + + const rootNode = element.getRootNode?.(); + if (typeof ShadowRoot !== 'undefined' && rootNode instanceof ShadowRoot) { + return rootNode.host as HTMLElement; + } + + return null; +}; + +const hasMatchingOverflow = (element: HTMLElement, overflowPattern: RegExp) => { + const style = window.getComputedStyle(element); + return overflowPattern.test(`${style.overflow}${style.overflowX}${style.overflowY}`); +}; + +export const getScrollableAncestors = (element: HTMLElement) => { + const ancestors: HTMLElement[] = []; + let parent = getParentElementAcrossShadowRoot(element); + + while (parent && parent !== document.body && parent !== document.documentElement) { + if (hasMatchingOverflow(parent, scrollableOverflowPattern)) { + ancestors.push(parent); + } + parent = getParentElementAcrossShadowRoot(parent); + } + + return ancestors; +}; + +export const getClippingAncestors = (element: HTMLElement) => { + const ancestors: HTMLElement[] = []; + let parent = getParentElementAcrossShadowRoot(element); + + while (parent && parent !== document.body && parent !== document.documentElement) { + if (hasMatchingOverflow(parent, clippingOverflowPattern)) { + ancestors.push(parent); + } + parent = getParentElementAcrossShadowRoot(parent); + } + + return ancestors; +}; + export const calculateVisibleElementData = (element: HTMLElement) => { const rect = element.getBoundingClientRect(); - const windowHeight = window.innerHeight; - const windowWidth = window.innerWidth; + let clipTop = 0; + let clipLeft = 0; + let clipBottom = window.innerHeight; + let clipRight = window.innerWidth; + + getClippingAncestors(element).forEach((ancestor) => { + const ancestorRect = ancestor.getBoundingClientRect(); + clipTop = Math.max(clipTop, ancestorRect.top); + clipLeft = Math.max(clipLeft, ancestorRect.left); + clipBottom = Math.min(clipBottom, ancestorRect.bottom); + clipRight = Math.min(clipRight, ancestorRect.right); + }); - const frameRelativeTop = Math.max(rect.top, 0); - const frameRelativeLeft = Math.max(rect.left, 0); + const frameRelativeTop = Math.max(rect.top, clipTop); + const frameRelativeLeft = Math.max(rect.left, clipLeft); - const frameRelativeBottom = Math.min(windowHeight, rect.bottom); - const frameRelativeRight = Math.min(windowWidth, rect.right); + const frameRelativeBottom = Math.min(clipBottom, rect.bottom); + const frameRelativeRight = Math.min(clipRight, rect.right); const data = { - top: Math.max(0, rect.top * -1), + top: Math.max(0, clipTop - rect.top), height: Math.max(0, frameRelativeBottom - frameRelativeTop), - left: Math.max(0, rect.left * -1), + left: Math.max(0, clipLeft - rect.left), width: Math.max(0, frameRelativeRight - frameRelativeLeft), }; From b49d474944da878336d1dca5f0d0a6b20576695d Mon Sep 17 00:00:00 2001 From: Ruchi Anand Date: Wed, 27 May 2026 11:10:48 +0530 Subject: [PATCH 2/7] fix for scroll container with full height + lazyloading --- src/embed/app.spec.ts | 1 + src/embed/app.ts | 23 +++++++++++++++++++++-- src/embed/liveboard.spec.ts | 1 + src/embed/liveboard.ts | 23 +++++++++++++++++++++-- src/utils.spec.ts | 12 ++++++------ src/utils.ts | 21 +++++++++++++-------- 6 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/embed/app.spec.ts b/src/embed/app.spec.ts index 98d5bb13a..6f9a81738 100644 --- a/src/embed/app.spec.ts +++ b/src/embed/app.spec.ts @@ -1736,6 +1736,7 @@ describe('App embed tests', () => { ...defaultViewConfig, fullHeight: true, lazyLoadingForFullHeight: true, + enableContainerAwareLazyLoadingForFullHeight: true, } as AppViewConfig); await appEmbed.render(); diff --git a/src/embed/app.ts b/src/embed/app.ts index a4eb4bb89..3c0e5c361 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -656,6 +656,16 @@ export interface AppViewConfig extends AllEmbedViewConfig { * ``` */ lazyLoadingForFullHeight?: boolean; + /** + * This flag is used to enable container-aware full height lazy loading. + * + * Use this when the embed is rendered inside a scrollable or clipping + * container instead of relying on the browser window as the only viewport. + * + * @type {boolean} + * @default false + */ + enableContainerAwareLazyLoadingForFullHeight?: boolean; /** * The margin to be used for lazy loading. @@ -1134,7 +1144,10 @@ export class AppEmbed extends V1Embed { } private sendFullHeightLazyLoadData = () => { - const data = calculateVisibleElementData(this.iFrame); + const data = calculateVisibleElementData( + this.iFrame, + this.viewConfig.enableContainerAwareLazyLoadingForFullHeight, + ); // this should be fired only if the lazyLoadingForFullHeight and fullHeight are true if(this.viewConfig.lazyLoadingForFullHeight && this.viewConfig.fullHeight){ this.trigger(HostEvent.VisibleEmbedCoordinates, data); @@ -1149,7 +1162,10 @@ export class AppEmbed extends V1Embed { */ private requestVisibleEmbedCoordinatesHandler = (data: MessagePayload, responder: any) => { logger.info('Sending RequestVisibleEmbedCoordinates', data); - const visibleCoordinatesData = calculateVisibleElementData(this.iFrame); + const visibleCoordinatesData = calculateVisibleElementData( + this.iFrame, + this.viewConfig.enableContainerAwareLazyLoadingForFullHeight, + ); responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); } @@ -1303,6 +1319,9 @@ export class AppEmbed extends V1Embed { // TODO: Use passive: true, install modernizr to check for passive window.addEventListener('resize', this.sendFullHeightLazyLoadData); window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); + if (!this.viewConfig.enableContainerAwareLazyLoadingForFullHeight) { + return; + } this.lazyLoadScrollContainers = getScrollableAncestors(this.iFrame); this.lazyLoadScrollContainers.forEach((scrollContainer) => { scrollContainer.addEventListener('scroll', this.sendFullHeightLazyLoadData); diff --git a/src/embed/liveboard.spec.ts b/src/embed/liveboard.spec.ts index 1e8737cab..64478b4e9 100644 --- a/src/embed/liveboard.spec.ts +++ b/src/embed/liveboard.spec.ts @@ -1852,6 +1852,7 @@ describe('Liveboard/viz embed tests', () => { liveboardId, fullHeight: true, lazyLoadingForFullHeight: true, + enableContainerAwareLazyLoadingForFullHeight: true, } as LiveboardViewConfig); await liveboardEmbed.render(); diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index 7e486fd9d..ce2c385e4 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -453,6 +453,16 @@ export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewC * ``` */ lazyLoadingForFullHeight?: boolean; + /** + * This flag is used to enable container-aware full height lazy loading. + * + * Use this when the embed is rendered inside a scrollable or clipping + * container instead of relying on the browser window as the only viewport. + * + * @type {boolean} + * @default false + */ + enableContainerAwareLazyLoadingForFullHeight?: boolean; /** * The margin to be used for lazy loading. * @@ -829,7 +839,10 @@ export class LiveboardEmbed extends V1Embed { } private sendFullHeightLazyLoadData = () => { - const data = calculateVisibleElementData(this.iFrame); + const data = calculateVisibleElementData( + this.iFrame, + this.viewConfig.enableContainerAwareLazyLoadingForFullHeight, + ); // this should be fired only if the lazyLoadingForFullHeight and fullHeight are true if(this.viewConfig.lazyLoadingForFullHeight && this.viewConfig.fullHeight){ this.trigger(HostEvent.VisibleEmbedCoordinates, data); @@ -844,7 +857,10 @@ export class LiveboardEmbed extends V1Embed { */ private requestVisibleEmbedCoordinatesHandler = (data: MessagePayload, responder: any) => { logger.info('Sending RequestVisibleEmbedCoordinates', data); - const visibleCoordinatesData = calculateVisibleElementData(this.iFrame); + const visibleCoordinatesData = calculateVisibleElementData( + this.iFrame, + this.viewConfig.enableContainerAwareLazyLoadingForFullHeight, + ); responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); } @@ -1032,6 +1048,9 @@ export class LiveboardEmbed extends V1Embed { // TODO: Use passive: true, install modernizr to check for passive window.addEventListener('resize', this.sendFullHeightLazyLoadData); window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); + if (!this.viewConfig.enableContainerAwareLazyLoadingForFullHeight) { + return; + } this.lazyLoadScrollContainers = getScrollableAncestors(this.iFrame); this.lazyLoadScrollContainers.forEach((scrollContainer) => { scrollContainer.addEventListener('scroll', this.sendFullHeightLazyLoadData); diff --git a/src/utils.spec.ts b/src/utils.spec.ts index 4d840850c..d212a52b3 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -521,7 +521,7 @@ describe('calculateVisibleElementData', () => { height: 200, } as DOMRect); - const result = calculateVisibleElementData(mockElement); + const result = calculateVisibleElementData(mockElement, true); expect(result).toEqual({ top: 0, // Not clipped from top @@ -542,7 +542,7 @@ describe('calculateVisibleElementData', () => { height: 200, } as DOMRect); - const result = calculateVisibleElementData(mockElement); + const result = calculateVisibleElementData(mockElement, true); expect(result).toEqual({ top: 50, // Clipped 50px from top @@ -563,7 +563,7 @@ describe('calculateVisibleElementData', () => { height: 200, } as DOMRect); - const result = calculateVisibleElementData(mockElement); + const result = calculateVisibleElementData(mockElement, true); expect(result).toEqual({ top: 0, // Not clipped from top @@ -584,7 +584,7 @@ describe('calculateVisibleElementData', () => { height: 350, } as DOMRect); - const result = calculateVisibleElementData(mockElement); + const result = calculateVisibleElementData(mockElement, true); expect(result).toEqual({ top: 0, // Not clipped from top @@ -743,7 +743,7 @@ describe('calculateVisibleElementData', () => { height: 1000, } as DOMRect); - const result = calculateVisibleElementData(mockElement); + const result = calculateVisibleElementData(mockElement, true); expect(result).toEqual({ top: 200, @@ -776,7 +776,7 @@ describe('calculateVisibleElementData', () => { height: 650, } as DOMRect); - const result = calculateVisibleElementData(mockElement); + const result = calculateVisibleElementData(mockElement, true); expect(result).toEqual({ top: 50, diff --git a/src/utils.ts b/src/utils.ts index 9e4321610..39b5610bf 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -567,7 +567,10 @@ export const getClippingAncestors = (element: HTMLElement) => { return ancestors; }; -export const calculateVisibleElementData = (element: HTMLElement) => { +export const calculateVisibleElementData = ( + element: HTMLElement, + useClippingAncestors = false, +) => { const rect = element.getBoundingClientRect(); let clipTop = 0; @@ -575,13 +578,15 @@ export const calculateVisibleElementData = (element: HTMLElement) => { let clipBottom = window.innerHeight; let clipRight = window.innerWidth; - getClippingAncestors(element).forEach((ancestor) => { - const ancestorRect = ancestor.getBoundingClientRect(); - clipTop = Math.max(clipTop, ancestorRect.top); - clipLeft = Math.max(clipLeft, ancestorRect.left); - clipBottom = Math.min(clipBottom, ancestorRect.bottom); - clipRight = Math.min(clipRight, ancestorRect.right); - }); + if (useClippingAncestors) { + getClippingAncestors(element).forEach((ancestor) => { + const ancestorRect = ancestor.getBoundingClientRect(); + clipTop = Math.max(clipTop, ancestorRect.top); + clipLeft = Math.max(clipLeft, ancestorRect.left); + clipBottom = Math.min(clipBottom, ancestorRect.bottom); + clipRight = Math.min(clipRight, ancestorRect.right); + }); + } const frameRelativeTop = Math.max(rect.top, clipTop); const frameRelativeLeft = Math.max(rect.left, clipLeft); From d9c5d020f0520f30c64f3c375a3f9582782a881a Mon Sep 17 00:00:00 2001 From: ruchI9897 <59597701+ruchI9897@users.noreply.github.com> Date: Wed, 27 May 2026 11:22:07 +0530 Subject: [PATCH 3/7] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/embed/app.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/embed/app.ts b/src/embed/app.ts index 3c0e5c361..c83961fd1 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -1314,6 +1314,9 @@ export class AppEmbed extends V1Embed { } 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 From dca6c70cb9192f1d5ebd89415f1280a62784c4e6 Mon Sep 17 00:00:00 2001 From: Ruchi Anand Date: Wed, 27 May 2026 11:25:43 +0530 Subject: [PATCH 4/7] claude comments --- src/utils.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/utils.ts b/src/utils.ts index 39b5610bf..d640c1554 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -541,6 +541,9 @@ const hasMatchingOverflow = (element: HTMLElement, overflowPattern: RegExp) => { export const getScrollableAncestors = (element: HTMLElement) => { const ancestors: HTMLElement[] = []; + if (!element) { + return ancestors; + } let parent = getParentElementAcrossShadowRoot(element); while (parent && parent !== document.body && parent !== document.documentElement) { @@ -555,6 +558,9 @@ export const getScrollableAncestors = (element: HTMLElement) => { export const getClippingAncestors = (element: HTMLElement) => { const ancestors: HTMLElement[] = []; + if (!element) { + return ancestors; + } let parent = getParentElementAcrossShadowRoot(element); while (parent && parent !== document.body && parent !== document.documentElement) { @@ -571,6 +577,14 @@ export const calculateVisibleElementData = ( element: HTMLElement, useClippingAncestors = false, ) => { + if (!element) { + return { + top: 0, + height: 0, + left: 0, + width: 0, + }; + } const rect = element.getBoundingClientRect(); let clipTop = 0; From a5fb68e9faa55f3bca315320cb63ed1fefe8d3b9 Mon Sep 17 00:00:00 2001 From: Ruchi Anand Date: Thu, 28 May 2026 12:34:10 +0530 Subject: [PATCH 5/7] fixed comments --- src/embed/app.ts | 4 +-- src/embed/liveboard.ts | 7 ++++-- src/utils.spec.ts | 57 ++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 45 +++++++++++++++++++++++++++++++-- 4 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/embed/app.ts b/src/embed/app.ts index c83961fd1..f18688021 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -9,7 +9,7 @@ */ import { logger } from '../utils/logger'; -import { calculateVisibleElementData, getClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; +import { calculateVisibleElementData, getEffectiveClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; import { Param, DOMSelector, @@ -1332,7 +1332,7 @@ export class AppEmbed extends V1Embed { if (typeof ResizeObserver !== 'undefined') { const resizeTargets = new Set([ this.iFrame.parentElement, - ...getClippingAncestors(this.iFrame), + ...getEffectiveClippingAncestors(this.iFrame), ].filter(Boolean) as HTMLElement[]); this.lazyLoadResizeObserver = new ResizeObserver(this.sendFullHeightLazyLoadData); resizeTargets.forEach((resizeTarget) => { diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index ce2c385e4..55befa00a 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -24,7 +24,7 @@ import { EmbedErrorCodes, ContextType, } from '../types'; -import { calculateVisibleElementData, getClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; +import { calculateVisibleElementData, getEffectiveClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; import { getAuthPromise } from './base'; import { TsEmbed, V1Embed } from './ts-embed'; import { addPreviewStylesIfNotPresent } from '../utils/global-styles'; @@ -1043,6 +1043,9 @@ export class LiveboardEmbed extends V1Embed { } 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 @@ -1058,7 +1061,7 @@ export class LiveboardEmbed extends V1Embed { if (typeof ResizeObserver !== 'undefined') { const resizeTargets = new Set([ this.iFrame.parentElement, - ...getClippingAncestors(this.iFrame), + ...getEffectiveClippingAncestors(this.iFrame), ].filter(Boolean) as HTMLElement[]); this.lazyLoadResizeObserver = new ResizeObserver(this.sendFullHeightLazyLoadData); resizeTargets.forEach((resizeTarget) => { diff --git a/src/utils.spec.ts b/src/utils.spec.ts index d212a52b3..c9c285b96 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -19,6 +19,7 @@ import { arrayIncludesString, calculateVisibleElementData, getClippingAncestors, + getEffectiveClippingAncestors, getScrollableAncestors, formatTemplate, isValidCssMargin, @@ -823,6 +824,62 @@ describe('getClippingAncestors', () => { }); }); +describe('getEffectiveClippingAncestors', () => { + it('should ignore overflow ancestors that do not clip the element', () => { + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + const iframe = document.createElement('iframe'); + + clippingContainer.appendChild(iframe); + + jest.spyOn(clippingContainer, 'getBoundingClientRect').mockReturnValue({ + top: 100, + left: 100, + bottom: 700, + right: 700, + width: 600, + height: 600, + } as DOMRect); + jest.spyOn(iframe, 'getBoundingClientRect').mockReturnValue({ + top: 200, + left: 200, + bottom: 400, + right: 400, + width: 200, + height: 200, + } as DOMRect); + + expect(getEffectiveClippingAncestors(iframe)).toEqual([]); + }); + + it('should include overflow ancestors that clip the element', () => { + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + const iframe = document.createElement('iframe'); + + clippingContainer.appendChild(iframe); + + jest.spyOn(clippingContainer, 'getBoundingClientRect').mockReturnValue({ + top: 100, + left: 100, + bottom: 500, + right: 500, + width: 400, + height: 400, + } as DOMRect); + jest.spyOn(iframe, 'getBoundingClientRect').mockReturnValue({ + top: 50, + left: 50, + bottom: 700, + right: 700, + width: 650, + height: 650, + } as DOMRect); + + expect(getEffectiveClippingAncestors(iframe)).toEqual([clippingContainer]); + }); +}); + describe('formatTemplate', () => { it('should replace placeholders with provided values', () => { expect( diff --git a/src/utils.ts b/src/utils.ts index d640c1554..7599f6598 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -536,7 +536,7 @@ const getParentElementAcrossShadowRoot = (element: HTMLElement): HTMLElement | n const hasMatchingOverflow = (element: HTMLElement, overflowPattern: RegExp) => { const style = window.getComputedStyle(element); - return overflowPattern.test(`${style.overflow}${style.overflowX}${style.overflowY}`); + return style ? overflowPattern.test(style.overflow + style.overflowX + style.overflowY) : false; }; export const getScrollableAncestors = (element: HTMLElement) => { @@ -573,6 +573,47 @@ export const getClippingAncestors = (element: HTMLElement) => { return ancestors; }; +const getIntersectedRect = ( + rect: Pick, + clipRect: Pick, +) => ({ + top: Math.max(rect.top, clipRect.top), + left: Math.max(rect.left, clipRect.left), + bottom: Math.min(rect.bottom, clipRect.bottom), + right: Math.min(rect.right, clipRect.right), +}); + +const isSameVisibleRect = ( + rectA: ReturnType, + rectB: ReturnType, +) => rectA.top === rectB.top + && rectA.left === rectB.left + && rectA.bottom === rectB.bottom + && rectA.right === rectB.right; + +export const getEffectiveClippingAncestors = (element: HTMLElement) => { + if (!element) { + return []; + } + + const elementRect = element.getBoundingClientRect(); + let clipRect = { + top: 0, + left: 0, + bottom: window.innerHeight, + right: window.innerWidth, + }; + + return getClippingAncestors(element).filter((ancestor) => { + const currentVisibleRect = getIntersectedRect(elementRect, clipRect); + const nextClipRect = getIntersectedRect(clipRect, ancestor.getBoundingClientRect()); + const nextVisibleRect = getIntersectedRect(elementRect, nextClipRect); + const clipsElement = !isSameVisibleRect(currentVisibleRect, nextVisibleRect); + clipRect = nextClipRect; + return clipsElement; + }); +}; + export const calculateVisibleElementData = ( element: HTMLElement, useClippingAncestors = false, @@ -593,7 +634,7 @@ export const calculateVisibleElementData = ( let clipRight = window.innerWidth; if (useClippingAncestors) { - getClippingAncestors(element).forEach((ancestor) => { + getEffectiveClippingAncestors(element).forEach((ancestor) => { const ancestorRect = ancestor.getBoundingClientRect(); clipTop = Math.max(clipTop, ancestorRect.top); clipLeft = Math.max(clipLeft, ancestorRect.left); From 2cfd4185458b5d70f3bec1547bc187cf91c6d664 Mon Sep 17 00:00:00 2001 From: Ruchi Anand Date: Thu, 28 May 2026 12:39:06 +0530 Subject: [PATCH 6/7] increased coverage --- src/utils.spec.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/utils.spec.ts b/src/utils.spec.ts index c9c285b96..7eed6830c 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -532,6 +532,15 @@ describe('calculateVisibleElementData', () => { }); }); + it('should return zero dimensions when element is missing', () => { + expect(calculateVisibleElementData(null as unknown as HTMLElement)).toEqual({ + top: 0, + height: 0, + left: 0, + width: 0, + }); + }); + it('should calculate data for element clipped from top', () => { // Mock getBoundingClientRect for element partially above viewport jest.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ @@ -789,6 +798,10 @@ describe('calculateVisibleElementData', () => { }); describe('getScrollableAncestors', () => { + it('should return an empty list when element is missing', () => { + expect(getScrollableAncestors(null as unknown as HTMLElement)).toEqual([]); + }); + it('should find scrollable ancestors inside a shadow root', () => { const host = document.createElement('div'); document.body.appendChild(host); @@ -807,9 +820,27 @@ describe('getScrollableAncestors', () => { host.remove(); }); + + it('should ignore ancestors when computed style is unavailable', () => { + const parent = document.createElement('div'); + const iframe = document.createElement('iframe'); + parent.appendChild(iframe); + + const getComputedStyleSpy = jest + .spyOn(window, 'getComputedStyle') + .mockReturnValue(null as unknown as CSSStyleDeclaration); + + expect(getScrollableAncestors(iframe)).toEqual([]); + + getComputedStyleSpy.mockRestore(); + }); }); describe('getClippingAncestors', () => { + it('should return an empty list when element is missing', () => { + expect(getClippingAncestors(null as unknown as HTMLElement)).toEqual([]); + }); + it('should include scrollable and non-scroll clipping ancestors', () => { const scrollContainer = document.createElement('div'); scrollContainer.style.overflow = 'auto'; @@ -825,6 +856,10 @@ describe('getClippingAncestors', () => { }); describe('getEffectiveClippingAncestors', () => { + it('should return an empty list when element is missing', () => { + expect(getEffectiveClippingAncestors(null as unknown as HTMLElement)).toEqual([]); + }); + it('should ignore overflow ancestors that do not clip the element', () => { const clippingContainer = document.createElement('div'); clippingContainer.style.overflow = 'hidden'; From fd397b9efd22ece5e4228f734858722e7b9f48e2 Mon Sep 17 00:00:00 2001 From: Ruchi Anand Date: Mon, 1 Jun 2026 12:29:40 +0530 Subject: [PATCH 7/7] flag rename --- src/embed/app.spec.ts | 2 +- src/embed/app.ts | 9 +++++---- src/embed/liveboard.spec.ts | 2 +- src/embed/liveboard.ts | 8 ++++---- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/embed/app.spec.ts b/src/embed/app.spec.ts index 6f9a81738..f1296638f 100644 --- a/src/embed/app.spec.ts +++ b/src/embed/app.spec.ts @@ -1736,7 +1736,7 @@ describe('App embed tests', () => { ...defaultViewConfig, fullHeight: true, lazyLoadingForFullHeight: true, - enableContainerAwareLazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: true, } as AppViewConfig); await appEmbed.render(); diff --git a/src/embed/app.ts b/src/embed/app.ts index f18688021..c6b372c6a 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -664,8 +664,9 @@ export interface AppViewConfig extends AllEmbedViewConfig { * * @type {boolean} * @default false + * @hidden */ - enableContainerAwareLazyLoadingForFullHeight?: boolean; + enableScrollableContainerLazyLoading?: boolean; /** * The margin to be used for lazy loading. @@ -1146,7 +1147,7 @@ export class AppEmbed extends V1Embed { private sendFullHeightLazyLoadData = () => { const data = calculateVisibleElementData( this.iFrame, - this.viewConfig.enableContainerAwareLazyLoadingForFullHeight, + this.viewConfig.enableScrollableContainerLazyLoading, ); // this should be fired only if the lazyLoadingForFullHeight and fullHeight are true if(this.viewConfig.lazyLoadingForFullHeight && this.viewConfig.fullHeight){ @@ -1164,7 +1165,7 @@ export class AppEmbed extends V1Embed { logger.info('Sending RequestVisibleEmbedCoordinates', data); const visibleCoordinatesData = calculateVisibleElementData( this.iFrame, - this.viewConfig.enableContainerAwareLazyLoadingForFullHeight, + this.viewConfig.enableScrollableContainerLazyLoading, ); responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); } @@ -1322,7 +1323,7 @@ export class AppEmbed extends V1Embed { // TODO: Use passive: true, install modernizr to check for passive window.addEventListener('resize', this.sendFullHeightLazyLoadData); window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); - if (!this.viewConfig.enableContainerAwareLazyLoadingForFullHeight) { + if (!this.viewConfig.enableScrollableContainerLazyLoading) { return; } this.lazyLoadScrollContainers = getScrollableAncestors(this.iFrame); diff --git a/src/embed/liveboard.spec.ts b/src/embed/liveboard.spec.ts index 64478b4e9..398104a4a 100644 --- a/src/embed/liveboard.spec.ts +++ b/src/embed/liveboard.spec.ts @@ -1852,7 +1852,7 @@ describe('Liveboard/viz embed tests', () => { liveboardId, fullHeight: true, lazyLoadingForFullHeight: true, - enableContainerAwareLazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: true, } as LiveboardViewConfig); await liveboardEmbed.render(); diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index 55befa00a..3f8c61314 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -462,7 +462,7 @@ export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewC * @type {boolean} * @default false */ - enableContainerAwareLazyLoadingForFullHeight?: boolean; + enableScrollableContainerLazyLoading?: boolean; /** * The margin to be used for lazy loading. * @@ -841,7 +841,7 @@ export class LiveboardEmbed extends V1Embed { private sendFullHeightLazyLoadData = () => { const data = calculateVisibleElementData( this.iFrame, - this.viewConfig.enableContainerAwareLazyLoadingForFullHeight, + this.viewConfig.enableScrollableContainerLazyLoading, ); // this should be fired only if the lazyLoadingForFullHeight and fullHeight are true if(this.viewConfig.lazyLoadingForFullHeight && this.viewConfig.fullHeight){ @@ -859,7 +859,7 @@ export class LiveboardEmbed extends V1Embed { logger.info('Sending RequestVisibleEmbedCoordinates', data); const visibleCoordinatesData = calculateVisibleElementData( this.iFrame, - this.viewConfig.enableContainerAwareLazyLoadingForFullHeight, + this.viewConfig.enableScrollableContainerLazyLoading, ); responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); } @@ -1051,7 +1051,7 @@ export class LiveboardEmbed extends V1Embed { // TODO: Use passive: true, install modernizr to check for passive window.addEventListener('resize', this.sendFullHeightLazyLoadData); window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); - if (!this.viewConfig.enableContainerAwareLazyLoadingForFullHeight) { + if (!this.viewConfig.enableScrollableContainerLazyLoading) { return; } this.lazyLoadScrollContainers = getScrollableAncestors(this.iFrame);