From b4167ee0608ce32e2496921e1c01cfe98ecdacd9 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Wed, 29 Jul 2026 16:59:07 +0530 Subject: [PATCH 1/9] SCAL-325100 Automate pre-render position sync via MutationObserver --- src/embed/ts-embed.spec.ts | 94 ++++++++++++++++++++++++++++++++++++++ src/embed/ts-embed.ts | 62 +++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index e1025f317..0bb83260d 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -2580,6 +2580,100 @@ describe('Unit test case for ts embed', () => { expect(document.getElementById(preRenderIds.wrapper)).toBe(null); }); + it('showPreRender should start a MutationObserver on placeholder ancestors', async () => { + createRootEleForEmbed(); + + // Give the host element a parent so there is at least one ancestor to + // observe between the placeholder and document.body. + const outerDiv = document.createElement('div'); + outerDiv.id = 'outer-wrapper'; + document.body.appendChild(outerDiv); + const hostEl = document.getElementById('tsEmbedDiv'); + outerDiv.appendChild(hostEl); + + const observeSpy = jest.spyOn(MutationObserver.prototype, 'observe'); + const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect'); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'mut-obs-test', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + // At least one observe() call should have been made on an ancestor. + expect(observeSpy).toHaveBeenCalled(); + // Each observed node should use the class/style attribute filter. + observeSpy.mock.calls.forEach(([, options]) => { + expect(options.attributes).toBe(true); + expect(options.attributeFilter).toEqual( + expect.arrayContaining(['class', 'style']), + ); + }); + + libEmbed.hidePreRender(); + expect(disconnectSpy).toHaveBeenCalled(); + + observeSpy.mockRestore(); + disconnectSpy.mockRestore(); + outerDiv.remove(); + }); + + it('showPreRender syncs position when an ancestor class changes', async () => { + createRootEleForEmbed(); + + const outerDiv = document.createElement('div'); + outerDiv.id = 'layout-root'; + document.body.appendChild(outerDiv); + const hostEl = document.getElementById('tsEmbedDiv'); + outerDiv.appendChild(hostEl); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'mut-obs-class-change', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); + + // Toggle a class on the ancestor — this is the "sidebar collapse" pattern. + // lastRect is null on the first mutation, so getBoundingClientRect will + // always detect a change and trigger sync. + outerDiv.classList.add('sidebar-collapsed'); + + // MutationObserver callbacks are microtasks; one await flushes them. + await Promise.resolve(); + + expect(syncSpy).toHaveBeenCalled(); + + syncSpy.mockRestore(); + libEmbed.destroy(); + outerDiv.remove(); + }); + + it('MutationObserver is NOT created when doNotTrackPreRenderSize is true', async () => { + createRootEleForEmbed(); + + const observeSpy = jest.spyOn(MutationObserver.prototype, 'observe'); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'mut-obs-disabled', + liveboardId: 'myLiveboardId', + doNotTrackPreRenderSize: true, + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + expect(observeSpy).not.toHaveBeenCalled(); + + observeSpy.mockRestore(); + libEmbed.destroy(); + }); + it('preRender called without preRenderId should log error ', () => { createRootEleForEmbed(); diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 1566499fb..8bd072341 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -206,6 +206,8 @@ export class TsEmbed { private resizeObserver: ResizeObserver; + private mutationObserver: MutationObserver | null = null; + private preRenderContainerEl: HTMLElement | null = null; private containerScrollListener: (() => void) | null = null; @@ -1896,6 +1898,63 @@ export class TsEmbed { this.containerScrollListener = null; } + /** + * Starts a MutationObserver that watches the placeholder's ancestor chain + * for `class` and `style` attribute changes. On each mutation batch, + * calls `getBoundingClientRect()` on the placeholder and only invokes + * `syncPreRenderStyle()` when the top/left position has actually changed. + * This covers layout shifts (e.g. sidebar collapse, theme toggle) that + * move the placeholder without resizing it — a gap ResizeObserver cannot fill. + */ + private startPositionObserver(): void { + if (this.mutationObserver) { + return; + } + const placeholder = this.getPreRenderPlaceHolderElement(); + if (!placeholder) { + return; + } + + let lastRect: DOMRect | null = null; + + this.mutationObserver = new MutationObserver(() => { + if (!this.isPreRenderConnected()) { + return; + } + const rect = placeholder.getBoundingClientRect(); + if (rect.top !== lastRect?.top || rect.left !== lastRect?.left) { + lastRect = rect; + this.syncPreRenderStyle(); + } + }); + + // Walk ancestors from the placeholder up to (and including) the + // container boundary. Class or style changes on any of these nodes + // can shift the placeholder's position without changing its size. + const boundary = this.preRenderContainerEl ?? document.body; + let el: Element | null = placeholder.parentElement; + while (el) { + this.mutationObserver.observe(el, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + if (el === boundary) { + break; + } + el = el.parentElement; + } + } + + /** + * Disconnects the position MutationObserver and clears the reference. + */ + private stopPositionObserver(): void { + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + this.mutationObserver = null; + } + } + /** * Destroys the ThoughtSpot embed, and remove any nodes from the DOM. * @version SDK: 1.19.1 | ThoughtSpot: * @@ -1904,6 +1963,7 @@ export class TsEmbed { try { this.removeFullscreenChangeHandler(); this.removeContainerScrollListener(); + this.stopPositionObserver(); this.unsubscribeToEvents(); this.preRenderWrapper?.remove(); this.restorePreRenderContainerPosition(); @@ -2048,6 +2108,7 @@ export class TsEmbed { }); }); this.resizeObserver.observe(observeTarget); + this.startPositionObserver(); } } @@ -2138,6 +2199,7 @@ export class TsEmbed { if (this.resizeObserver) { this.resizeObserver.disconnect(); } + this.stopPositionObserver(); const placeHolderEle = this.getPreRenderPlaceHolderElement(); if (placeHolderEle) { From bc2165b56c667745af5922e20b227212d3b1e251 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 30 Jul 2026 10:23:46 +0530 Subject: [PATCH 2/9] SCAL-325100 added positionObserverRafId and windowResizeListener for calc --- src/embed/ts-embed.spec.ts | 86 ++++++++++++++++++++++++++++++++++++-- src/embed/ts-embed.ts | 64 ++++++++++++++++++++++------ 2 files changed, 134 insertions(+), 16 deletions(-) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 0bb83260d..16bcd8a1b 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -2604,12 +2604,14 @@ describe('Unit test case for ts embed', () => { // At least one observe() call should have been made on an ancestor. expect(observeSpy).toHaveBeenCalled(); - // Each observed node should use the class/style attribute filter. + // Each observed node should use the class/style attribute filter + // and also watch childList so DOM insertions/removals are caught. observeSpy.mock.calls.forEach(([, options]) => { expect(options.attributes).toBe(true); expect(options.attributeFilter).toEqual( expect.arrayContaining(['class', 'style']), ); + expect(options.childList).toBe(true); }); libEmbed.hidePreRender(); @@ -2629,6 +2631,11 @@ describe('Unit test case for ts embed', () => { const hostEl = document.getElementById('tsEmbedDiv'); outerDiv.appendChild(hostEl); + // Make rAF synchronous so the debounce gate fires immediately. + const rafSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((cb) => { cb(0); return 0; }); + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'mut-obs-class-change', liveboardId: 'myLiveboardId', @@ -2640,20 +2647,91 @@ describe('Unit test case for ts embed', () => { const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); // Toggle a class on the ancestor — this is the "sidebar collapse" pattern. - // lastRect is null on the first mutation, so getBoundingClientRect will - // always detect a change and trigger sync. + // lastTop/lastLeft are null on the first mutation, so getBoundingClientRect + // will always detect a change and trigger sync. outerDiv.classList.add('sidebar-collapsed'); - // MutationObserver callbacks are microtasks; one await flushes them. + // MutationObserver callback fires synchronously in JSDOM, which calls + // scheduleSync → rAF (now sync) → checkAndSync. One microtask tick + // is enough to observe the result. await Promise.resolve(); expect(syncSpy).toHaveBeenCalled(); + rafSpy.mockRestore(); syncSpy.mockRestore(); libEmbed.destroy(); outerDiv.remove(); }); + it('showPreRender syncs position when a sibling is added to an ancestor', async () => { + createRootEleForEmbed(); + + const outerDiv = document.createElement('div'); + outerDiv.id = 'layout-root-childlist'; + document.body.appendChild(outerDiv); + const hostEl = document.getElementById('tsEmbedDiv'); + outerDiv.appendChild(hostEl); + + const rafSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((cb) => { cb(0); return 0; }); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'mut-obs-childlist', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); + + // Inserting a sibling banner above the host element simulates a + // notification panel that pushes content down — a childList change + // that shifts position without changing class or style attributes. + const banner = document.createElement('div'); + banner.id = 'notification-banner'; + outerDiv.insertBefore(banner, hostEl); + + await Promise.resolve(); + + expect(syncSpy).toHaveBeenCalled(); + + rafSpy.mockRestore(); + syncSpy.mockRestore(); + libEmbed.destroy(); + outerDiv.remove(); + }); + + it('showPreRender syncs position on window resize', async () => { + createRootEleForEmbed(); + + const rafSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((cb) => { cb(0); return 0; }); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'mut-obs-resize', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); + + window.dispatchEvent(new Event('resize')); + + await Promise.resolve(); + + expect(syncSpy).toHaveBeenCalled(); + + rafSpy.mockRestore(); + syncSpy.mockRestore(); + libEmbed.destroy(); + }); + it('MutationObserver is NOT created when doNotTrackPreRenderSize is true', async () => { createRootEleForEmbed(); diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 8bd072341..59dfce98b 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -208,6 +208,10 @@ export class TsEmbed { private mutationObserver: MutationObserver | null = null; + private positionObserverRafId: number | null = null; + + private windowResizeListener: (() => void) | null = null; + private preRenderContainerEl: HTMLElement | null = null; private containerScrollListener: (() => void) | null = null; @@ -1900,11 +1904,21 @@ export class TsEmbed { /** * Starts a MutationObserver that watches the placeholder's ancestor chain - * for `class` and `style` attribute changes. On each mutation batch, - * calls `getBoundingClientRect()` on the placeholder and only invokes - * `syncPreRenderStyle()` when the top/left position has actually changed. - * This covers layout shifts (e.g. sidebar collapse, theme toggle) that - * move the placeholder without resizing it — a gap ResizeObserver cannot fill. + * for layout-triggering DOM changes and syncs the pre-render wrapper + * position when the placeholder actually moves. + * + * Three signal sources are combined: + * 1. `attributes` (class/style) on ancestors — CSS-driven layout shifts + * (sidebar collapse, theme toggle, etc.) + * 2. `childList` on ancestors — element additions/removals that push + * content around (notification banners, inserted panels, etc.) + * 3. `window` resize — viewport changes that shift position without + * resizing the placeholder itself. + * + * All signals are funnelled through a single `requestAnimationFrame` + * gate so that (a) rapid mutation bursts collapse into one measurement + * and (b) `getBoundingClientRect()` is called after the browser has + * finished computing layout, avoiding mid-transition readings. */ private startPositionObserver(): void { if (this.mutationObserver) { @@ -1915,21 +1929,34 @@ export class TsEmbed { return; } - let lastRect: DOMRect | null = null; + let lastTop: number | null = null; + let lastLeft: number | null = null; - this.mutationObserver = new MutationObserver(() => { + const checkAndSync = () => { + this.positionObserverRafId = null; if (!this.isPreRenderConnected()) { return; } const rect = placeholder.getBoundingClientRect(); - if (rect.top !== lastRect?.top || rect.left !== lastRect?.left) { - lastRect = rect; + if (rect.top !== lastTop || rect.left !== lastLeft) { + lastTop = rect.top; + lastLeft = rect.left; this.syncPreRenderStyle(); } - }); + }; + + const scheduleSync = () => { + if (this.positionObserverRafId !== null) { + return; + } + this.positionObserverRafId = requestAnimationFrame(checkAndSync); + }; + + this.mutationObserver = new MutationObserver(scheduleSync); // Walk ancestors from the placeholder up to (and including) the - // container boundary. Class or style changes on any of these nodes + // container boundary. Both attribute mutations (class/style toggles) + // and childList mutations (added/removed siblings) on any ancestor // can shift the placeholder's position without changing its size. const boundary = this.preRenderContainerEl ?? document.body; let el: Element | null = placeholder.parentElement; @@ -1937,22 +1964,35 @@ export class TsEmbed { this.mutationObserver.observe(el, { attributes: true, attributeFilter: ['class', 'style'], + childList: true, }); if (el === boundary) { break; } el = el.parentElement; } + + this.windowResizeListener = scheduleSync; + window.addEventListener('resize', this.windowResizeListener); } /** - * Disconnects the position MutationObserver and clears the reference. + * Disconnects the position MutationObserver, cancels any pending + * animation frame, and removes the window resize listener. */ private stopPositionObserver(): void { if (this.mutationObserver) { this.mutationObserver.disconnect(); this.mutationObserver = null; } + if (this.positionObserverRafId !== null) { + cancelAnimationFrame(this.positionObserverRafId); + this.positionObserverRafId = null; + } + if (this.windowResizeListener) { + window.removeEventListener('resize', this.windowResizeListener); + this.windowResizeListener = null; + } } /** From 51de7f768bd9d8bcb41761107d3dae21f4900b96 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 30 Jul 2026 11:25:00 +0530 Subject: [PATCH 3/9] SCAL-325100 added more test --- src/embed/ts-embed.spec.ts | 256 +++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 16bcd8a1b..36f073d8c 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -2752,6 +2752,262 @@ describe('Unit test case for ts embed', () => { libEmbed.destroy(); }); + it('does NOT sync when position has not changed between mutations', async () => { + createRootEleForEmbed(); + + const outerDiv = document.createElement('div'); + outerDiv.id = 'pos-guard-root'; + document.body.appendChild(outerDiv); + document.getElementById('tsEmbedDiv') && + outerDiv.appendChild(document.getElementById('tsEmbedDiv')); + + const rafSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((cb) => { cb(0); return 0; }); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'pos-guard', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + // First mutation sets lastTop/lastLeft from getBoundingClientRect. + outerDiv.classList.add('first-change'); + await Promise.resolve(); + + const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); + + // Second mutation — getBoundingClientRect returns the same values + // (JSDOM always returns zeroes), so the position guard should skip sync. + outerDiv.classList.add('second-change'); + await Promise.resolve(); + + expect(syncSpy).not.toHaveBeenCalled(); + + rafSpy.mockRestore(); + syncSpy.mockRestore(); + libEmbed.destroy(); + outerDiv.remove(); + }); + + it('rapid mutations schedule only one rAF (debounce gate)', async () => { + createRootEleForEmbed(); + + const outerDiv = document.createElement('div'); + outerDiv.id = 'raf-dedup-root'; + document.body.appendChild(outerDiv); + document.getElementById('tsEmbedDiv') && + outerDiv.appendChild(document.getElementById('tsEmbedDiv')); + + // Capture scheduled callbacks without executing them immediately. + const pendingCbs: FrameRequestCallback[] = []; + const rafSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((cb) => { pendingCbs.push(cb); return pendingCbs.length; }); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'raf-dedup', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + // Fire three mutations before any rAF flushes. + outerDiv.classList.add('change-1'); + outerDiv.classList.add('change-2'); + outerDiv.classList.add('change-3'); + await Promise.resolve(); + + // Only one rAF should have been scheduled regardless of how many + // mutation callbacks fired. + expect(rafSpy).toHaveBeenCalledTimes(1); + + rafSpy.mockRestore(); + libEmbed.destroy(); + outerDiv.remove(); + }); + + it('ancestor style attribute change triggers position sync', async () => { + createRootEleForEmbed(); + + const outerDiv = document.createElement('div'); + outerDiv.id = 'style-attr-root'; + document.body.appendChild(outerDiv); + document.getElementById('tsEmbedDiv') && + outerDiv.appendChild(document.getElementById('tsEmbedDiv')); + + const rafSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((cb) => { cb(0); return 0; }); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'style-attr', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); + + // Changing inline style on an ancestor (e.g. sidebar width override) + // should be caught by the attributeFilter: ['class', 'style'] observer. + outerDiv.style.marginLeft = '240px'; + await Promise.resolve(); + + expect(syncSpy).toHaveBeenCalled(); + + rafSpy.mockRestore(); + syncSpy.mockRestore(); + libEmbed.destroy(); + outerDiv.remove(); + }); + + it('window resize listener is removed when hidePreRender is called', async () => { + createRootEleForEmbed(); + + const rafSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((cb) => { cb(0); return 0; }); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'resize-cleanup-hide', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + const removeListenerSpy = jest.spyOn(window, 'removeEventListener'); + libEmbed.hidePreRender(); + + const removedResize = removeListenerSpy.mock.calls.some( + ([event]) => event === 'resize', + ); + expect(removedResize).toBe(true); + + rafSpy.mockRestore(); + removeListenerSpy.mockRestore(); + }); + + it('window resize listener is removed when destroy is called', async () => { + createRootEleForEmbed(); + + const rafSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((cb) => { cb(0); return 0; }); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'resize-cleanup-destroy', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + const removeListenerSpy = jest.spyOn(window, 'removeEventListener'); + libEmbed.destroy(); + + const removedResize = removeListenerSpy.mock.calls.some( + ([event]) => event === 'resize', + ); + expect(removedResize).toBe(true); + + rafSpy.mockRestore(); + removeListenerSpy.mockRestore(); + }); + + it('pending rAF is cancelled when stopPositionObserver runs', async () => { + createRootEleForEmbed(); + + const outerDiv = document.createElement('div'); + outerDiv.id = 'raf-cancel-root'; + document.body.appendChild(outerDiv); + document.getElementById('tsEmbedDiv') && + outerDiv.appendChild(document.getElementById('tsEmbedDiv')); + + // Hold the rAF callback without executing it so a cancellation can occur. + let scheduledId = 0; + const rafSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation(() => { scheduledId = ++scheduledId; return scheduledId; }); + const cancelSpy = jest.spyOn(global, 'cancelAnimationFrame'); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'raf-cancel', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + // Trigger a mutation so a rAF is pending. + outerDiv.classList.add('pending'); + await Promise.resolve(); + + // hidePreRender calls stopPositionObserver which should cancel the rAF. + libEmbed.hidePreRender(); + expect(cancelSpy).toHaveBeenCalled(); + + rafSpy.mockRestore(); + cancelSpy.mockRestore(); + outerDiv.remove(); + }); + + it('MutationObserver is not started twice when showPreRender is called twice', async () => { + createRootEleForEmbed(); + + const observeSpy = jest.spyOn(MutationObserver.prototype, 'observe'); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'idempotent-observer', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + const callsAfterFirst = observeSpy.mock.calls.length; + + // Calling showPreRender again should not add more observe() calls. + await libEmbed.showPreRender(); + expect(observeSpy.mock.calls.length).toBe(callsAfterFirst); + + observeSpy.mockRestore(); + libEmbed.destroy(); + }); + + it('no sync is triggered after hidePreRender even if resize fires', async () => { + createRootEleForEmbed(); + + const rafSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((cb) => { cb(0); return 0; }); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'no-sync-after-hide', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + libEmbed.hidePreRender(); + + const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); + + // Fire resize after the observer has been torn down. + window.dispatchEvent(new Event('resize')); + await Promise.resolve(); + + expect(syncSpy).not.toHaveBeenCalled(); + + rafSpy.mockRestore(); + syncSpy.mockRestore(); + }); + it('preRender called without preRenderId should log error ', () => { createRootEleForEmbed(); From fd81d136b242ccc818b04c500e3ba8a7833a7f74 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 30 Jul 2026 11:37:03 +0530 Subject: [PATCH 4/9] SCAL-325100 fixed lint issue --- src/embed/ts-embed.spec.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 36f073d8c..9d3aa69ff 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -2758,8 +2758,8 @@ describe('Unit test case for ts embed', () => { const outerDiv = document.createElement('div'); outerDiv.id = 'pos-guard-root'; document.body.appendChild(outerDiv); - document.getElementById('tsEmbedDiv') && - outerDiv.appendChild(document.getElementById('tsEmbedDiv')); + const hostElGuard = document.getElementById('tsEmbedDiv'); + if (hostElGuard) outerDiv.appendChild(hostElGuard); const rafSpy = jest .spyOn(global, 'requestAnimationFrame') @@ -2773,14 +2773,14 @@ describe('Unit test case for ts embed', () => { await waitFor(() => !!getIFrameEl()); await libEmbed.showPreRender(); - // First mutation sets lastTop/lastLeft from getBoundingClientRect. + // First mutation seeds lastTop/lastLeft via getBoundingClientRect. outerDiv.classList.add('first-change'); await Promise.resolve(); const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); - // Second mutation — getBoundingClientRect returns the same values - // (JSDOM always returns zeroes), so the position guard should skip sync. + // Second mutation — JSDOM always returns zeroes from + // getBoundingClientRect, so top/left are unchanged → no sync. outerDiv.classList.add('second-change'); await Promise.resolve(); @@ -2798,8 +2798,8 @@ describe('Unit test case for ts embed', () => { const outerDiv = document.createElement('div'); outerDiv.id = 'raf-dedup-root'; document.body.appendChild(outerDiv); - document.getElementById('tsEmbedDiv') && - outerDiv.appendChild(document.getElementById('tsEmbedDiv')); + const hostElDedup = document.getElementById('tsEmbedDiv'); + if (hostElDedup) outerDiv.appendChild(hostElDedup); // Capture scheduled callbacks without executing them immediately. const pendingCbs: FrameRequestCallback[] = []; @@ -2836,8 +2836,8 @@ describe('Unit test case for ts embed', () => { const outerDiv = document.createElement('div'); outerDiv.id = 'style-attr-root'; document.body.appendChild(outerDiv); - document.getElementById('tsEmbedDiv') && - outerDiv.appendChild(document.getElementById('tsEmbedDiv')); + const hostElStyle = document.getElementById('tsEmbedDiv'); + if (hostElStyle) outerDiv.appendChild(hostElStyle); const rafSpy = jest .spyOn(global, 'requestAnimationFrame') @@ -2853,8 +2853,8 @@ describe('Unit test case for ts embed', () => { const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); - // Changing inline style on an ancestor (e.g. sidebar width override) - // should be caught by the attributeFilter: ['class', 'style'] observer. + // Changing inline style on an ancestor (sidebar width override) is + // caught by attributeFilter: ['class', 'style']. outerDiv.style.marginLeft = '240px'; await Promise.resolve(); @@ -2926,10 +2926,10 @@ describe('Unit test case for ts embed', () => { const outerDiv = document.createElement('div'); outerDiv.id = 'raf-cancel-root'; document.body.appendChild(outerDiv); - document.getElementById('tsEmbedDiv') && - outerDiv.appendChild(document.getElementById('tsEmbedDiv')); + const hostElCancel = document.getElementById('tsEmbedDiv'); + if (hostElCancel) outerDiv.appendChild(hostElCancel); - // Hold the rAF callback without executing it so a cancellation can occur. + // Hold the rAF without executing so a cancellation can occur. let scheduledId = 0; const rafSpy = jest .spyOn(global, 'requestAnimationFrame') From 4f2893d379e69e87377235190fc7aa7197ec59df Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 30 Jul 2026 11:55:22 +0530 Subject: [PATCH 5/9] SCAL-325100 remvoe comment --- src/embed/ts-embed.ts | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 59dfce98b..0c81bfdbd 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -1902,24 +1902,6 @@ export class TsEmbed { this.containerScrollListener = null; } - /** - * Starts a MutationObserver that watches the placeholder's ancestor chain - * for layout-triggering DOM changes and syncs the pre-render wrapper - * position when the placeholder actually moves. - * - * Three signal sources are combined: - * 1. `attributes` (class/style) on ancestors — CSS-driven layout shifts - * (sidebar collapse, theme toggle, etc.) - * 2. `childList` on ancestors — element additions/removals that push - * content around (notification banners, inserted panels, etc.) - * 3. `window` resize — viewport changes that shift position without - * resizing the placeholder itself. - * - * All signals are funnelled through a single `requestAnimationFrame` - * gate so that (a) rapid mutation bursts collapse into one measurement - * and (b) `getBoundingClientRect()` is called after the browser has - * finished computing layout, avoiding mid-transition readings. - */ private startPositionObserver(): void { if (this.mutationObserver) { return; @@ -1954,10 +1936,6 @@ export class TsEmbed { this.mutationObserver = new MutationObserver(scheduleSync); - // Walk ancestors from the placeholder up to (and including) the - // container boundary. Both attribute mutations (class/style toggles) - // and childList mutations (added/removed siblings) on any ancestor - // can shift the placeholder's position without changing its size. const boundary = this.preRenderContainerEl ?? document.body; let el: Element | null = placeholder.parentElement; while (el) { @@ -1976,10 +1954,6 @@ export class TsEmbed { window.addEventListener('resize', this.windowResizeListener); } - /** - * Disconnects the position MutationObserver, cancels any pending - * animation frame, and removes the window resize listener. - */ private stopPositionObserver(): void { if (this.mutationObserver) { this.mutationObserver.disconnect(); From 40513df6c7aa141477695c9652119b681be694bb Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 30 Jul 2026 12:37:01 +0530 Subject: [PATCH 6/9] SCAL-325100 added test --- src/embed/ts-embed.spec.ts | 158 +++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 9d3aa69ff..89efe20f9 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -3008,6 +3008,164 @@ describe('Unit test case for ts embed', () => { syncSpy.mockRestore(); }); + it('container scroll triggers sync when preRenderContainer is set', async () => { + createRootEleForEmbed(); + + const customContainer = document.createElement('div'); + customContainer.id = 'custom-scroll-container'; + document.body.appendChild(customContainer); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'scroll-container-sync', + liveboardId: 'myLiveboardId', + preRenderContainer: '#custom-scroll-container', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); + + customContainer.dispatchEvent(new Event('scroll')); + + expect(syncSpy).toHaveBeenCalled(); + + syncSpy.mockRestore(); + libEmbed.destroy(); + customContainer.remove(); + }); + + it('container scroll still syncs when doNotTrackPreRenderSize is true', async () => { + createRootEleForEmbed(); + + const customContainer = document.createElement('div'); + customContainer.id = 'custom-scroll-no-track'; + document.body.appendChild(customContainer); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'scroll-no-track', + liveboardId: 'myLiveboardId', + preRenderContainer: '#custom-scroll-no-track', + doNotTrackPreRenderSize: true, + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); + + customContainer.dispatchEvent(new Event('scroll')); + + expect(syncSpy).toHaveBeenCalled(); + + syncSpy.mockRestore(); + libEmbed.destroy(); + customContainer.remove(); + }); + + it('observer only watches ancestors up to preRenderContainerEl boundary', async () => { + createRootEleForEmbed(); + + const container = document.createElement('div'); + container.id = 'boundary-container'; + document.body.appendChild(container); + + const outerDiv = document.createElement('div'); + outerDiv.id = 'outside-boundary'; + container.appendChild(outerDiv); + + const hostEl = document.getElementById('tsEmbedDiv'); + outerDiv.appendChild(hostEl); + + const observeSpy = jest.spyOn(MutationObserver.prototype, 'observe'); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'boundary-test', + liveboardId: 'myLiveboardId', + preRenderContainer: '#boundary-container', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + // Every observed node must be the container or a descendant of it — + // document.body should never be observed when a boundary is set. + const observedNodes = observeSpy.mock.calls.map(([node]) => node); + expect(observedNodes.some((n) => n === document.body)).toBe(false); + + observeSpy.mockRestore(); + libEmbed.destroy(); + container.remove(); + }); + + it('all ancestor layers between placeholder and boundary are observed', async () => { + createRootEleForEmbed(); + + const grandparent = document.createElement('div'); + grandparent.id = 'grandparent'; + document.body.appendChild(grandparent); + + const parent = document.createElement('div'); + parent.id = 'parent-layer'; + grandparent.appendChild(parent); + + const hostEl = document.getElementById('tsEmbedDiv'); + parent.appendChild(hostEl); + + const observeSpy = jest.spyOn(MutationObserver.prototype, 'observe'); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'multi-ancestor', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + // At least two ancestor layers (parent + grandparent) should be observed. + expect(observeSpy.mock.calls.length).toBeGreaterThanOrEqual(2); + + observeSpy.mockRestore(); + libEmbed.destroy(); + grandparent.remove(); + }); + + it('checkAndSync does not sync when isPreRenderConnected returns false', async () => { + createRootEleForEmbed(); + + // Hold the rAF callback so we can control exactly when it fires. + let pendingCb: FrameRequestCallback | null = null; + const rafSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((cb) => { pendingCb = cb; return 1; }); + + const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'check-and-sync-bail', + liveboardId: 'myLiveboardId', + }); + libEmbed.preRender(); + await waitFor(() => !!getIFrameEl()); + await libEmbed.showPreRender(); + + // Schedule a rAF (hold without executing). + window.dispatchEvent(new Event('resize')); + await Promise.resolve(); + + const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); + + // Simulate the pre-render becoming disconnected (e.g. wrapper removed) + // before the pending rAF fires. destroy() removes the DOM node but + // does not null out the class references, so we mock the guard directly. + jest.spyOn(libEmbed as any, 'isPreRenderConnected').mockReturnValue(false); + if (pendingCb) pendingCb(0); + + expect(syncSpy).not.toHaveBeenCalled(); + + rafSpy.mockRestore(); + syncSpy.mockRestore(); + libEmbed.destroy(); + }); + it('preRender called without preRenderId should log error ', () => { createRootEleForEmbed(); From 261f1c874b5f8ada3f808e6604381ab597386879 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 30 Jul 2026 16:28:47 +0530 Subject: [PATCH 7/9] SCAL-325100 added correct name --- src/embed/ts-embed.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 0c81bfdbd..0c218c6e2 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -1911,18 +1911,18 @@ export class TsEmbed { return; } - let lastTop: number | null = null; - let lastLeft: number | null = null; + let previousTop: number | null = null; + let previousLeft: number | null = null; const checkAndSync = () => { this.positionObserverRafId = null; if (!this.isPreRenderConnected()) { return; } - const rect = placeholder.getBoundingClientRect(); - if (rect.top !== lastTop || rect.left !== lastLeft) { - lastTop = rect.top; - lastLeft = rect.left; + const placeholderRect = placeholder.getBoundingClientRect(); + if (placeholderRect.top !== previousTop || placeholderRect.left !== previousLeft) { + previousTop = placeholderRect.top; + previousLeft = placeholderRect.left; this.syncPreRenderStyle(); } }; @@ -1936,18 +1936,18 @@ export class TsEmbed { this.mutationObserver = new MutationObserver(scheduleSync); - const boundary = this.preRenderContainerEl ?? document.body; - let el: Element | null = placeholder.parentElement; - while (el) { - this.mutationObserver.observe(el, { + const observeBoundary = this.preRenderContainerEl ?? document.body; + let currentAncestor: Element | null = placeholder.parentElement; + while (currentAncestor) { + this.mutationObserver.observe(currentAncestor, { attributes: true, attributeFilter: ['class', 'style'], childList: true, }); - if (el === boundary) { + if (currentAncestor === observeBoundary) { break; } - el = el.parentElement; + currentAncestor = currentAncestor.parentElement; } this.windowResizeListener = scheduleSync; From 258b2bd4f92e2ffdc9b5a5bba3ee3fc3731532b7 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Tue, 4 Aug 2026 06:02:20 +0530 Subject: [PATCH 8/9] SCAL-325100 added some restriction on chilslist --- src/embed/ts-embed.spec.ts | 10 +++++++--- src/embed/ts-embed.ts | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 89efe20f9..8314a4163 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -2604,14 +2604,18 @@ describe('Unit test case for ts embed', () => { // At least one observe() call should have been made on an ancestor. expect(observeSpy).toHaveBeenCalled(); - // Each observed node should use the class/style attribute filter - // and also watch childList so DOM insertions/removals are caught. + // Every observed node watches class/style attributes. observeSpy.mock.calls.forEach(([, options]) => { expect(options.attributes).toBe(true); expect(options.attributeFilter).toEqual( expect.arrayContaining(['class', 'style']), ); - expect(options.childList).toBe(true); + }); + // Only the first observe() call (direct parent) has childList: true. + // Higher ancestors omit it to avoid firing on table/list mutations. + expect(observeSpy.mock.calls[0][1].childList).toBe(true); + observeSpy.mock.calls.slice(1).forEach(([, options]) => { + expect(options.childList).toBeFalsy(); }); libEmbed.hidePreRender(); diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 0c218c6e2..915039834 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -1939,10 +1939,11 @@ export class TsEmbed { const observeBoundary = this.preRenderContainerEl ?? document.body; let currentAncestor: Element | null = placeholder.parentElement; while (currentAncestor) { + const isDirectParent = currentAncestor === placeholder.parentElement; this.mutationObserver.observe(currentAncestor, { attributes: true, attributeFilter: ['class', 'style'], - childList: true, + childList: isDirectParent, }); if (currentAncestor === observeBoundary) { break; From b6e2d42d357a996b2b14c4df259f2b0bec03f93a Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Tue, 4 Aug 2026 06:02:41 +0530 Subject: [PATCH 9/9] SCAL-325100 added some restriction on chilslist --- src/embed/ts-embed.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 8314a4163..a29e5804b 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -2668,7 +2668,7 @@ describe('Unit test case for ts embed', () => { outerDiv.remove(); }); - it('showPreRender syncs position when a sibling is added to an ancestor', async () => { + it('showPreRender syncs position when a sibling is added to the direct parent', async () => { createRootEleForEmbed(); const outerDiv = document.createElement('div'); @@ -2691,12 +2691,12 @@ describe('Unit test case for ts embed', () => { const syncSpy = jest.spyOn(libEmbed, 'syncPreRenderStyle'); - // Inserting a sibling banner above the host element simulates a - // notification panel that pushes content down — a childList change - // that shifts position without changing class or style attributes. + // Insert a sibling into the placeholder's direct parent (hostEl). + // childList is only observed on the direct parent to avoid firing on + // table-row mutations in sibling subtrees. const banner = document.createElement('div'); banner.id = 'notification-banner'; - outerDiv.insertBefore(banner, hostEl); + hostEl.insertBefore(banner, hostEl.firstChild); await Promise.resolve();