fix: Layout shift in inline embed caused due to showing embed before it was really ready (#24427)

This commit is contained in:
Hariom Balhara
2025-10-13 12:29:31 -03:00
committed by GitHub
parent ff38d6c7db
commit bed76beb2f
5 changed files with 354 additions and 101 deletions
+9 -93
View File
@@ -4,7 +4,13 @@ import { useEffect, useRef, useState, useCallback } from "react";
import type { Message } from "./embed";
import { embedStore, EMBED_IFRAME_STATE } from "./embed-iframe/lib/embedStore";
import { runAsap, isBookerReady, isLinkReady, recordResponseIfQueued } from "./embed-iframe/lib/utils";
import {
runAsap,
isBookerReady,
isLinkReady,
recordResponseIfQueued,
keepParentInformedAboutDimensionChanges,
} from "./embed-iframe/lib/utils";
import { sdkActionManager } from "./sdk-event";
import type {
UiConfig,
@@ -195,7 +201,6 @@ export const useEmbedStyles = (elementName: keyof EmbedStyles) => {
useEffect(() => {
return registerNewSetter({ elementName, setState: setStyles, styles: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const styles = embedStore.styles || {};
// Always read the data from global embedStore so that even across components, the same data is used.
@@ -207,7 +212,6 @@ export const useEmbedNonStylesConfig = (elementName: keyof EmbedNonStylesConfig)
useEffect(() => {
return registerNewSetter({ elementName, setState: setNonStyles, styles: false });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Always read the data from global embedStore so that even across components, the same data is used.
@@ -387,7 +391,6 @@ export const methods = {
setEmbedStyles(stylesConfig || {});
setEmbedNonStyles(stylesConfig || {});
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
parentKnowsIframeReady: (_unused: unknown) => {
log("Method: `parentKnowsIframeReady` called");
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
@@ -425,7 +428,7 @@ export const methods = {
...queryParamsFromConfig
} = config;
// We reset it to allow informing parent again through `__dimensionChanged` event about possibly updated dimensions with changes in config
embedStore.parentInformedAboutContentHeight = false;
embedStore.providedCorrectHeightToParent = false;
if (noSlotsFetchOnConnect !== "true") {
log("Method: connect, noSlotsFetchOnConnect is false. Requesting slots re-fetch");
@@ -484,93 +487,6 @@ const messageParent = (data: CustomEvent["detail"]) => {
);
};
/**
* This function is called once the iframe loads.
* It isn't called on "connect"
*/
function keepParentInformedAboutDimensionChanges() {
let knownIframeHeight: number | null = null;
let knownIframeWidth: number | null = null;
let isFirstTime = true;
let isWindowLoadComplete = false;
runAsap(function informAboutScroll() {
if (document.readyState !== "complete") {
// Wait for window to load to correctly calculate the initial scroll height.
runAsap(informAboutScroll);
return;
}
if (!isWindowLoadComplete) {
// On Safari, even though document.readyState is complete, still the page is not rendered and we can't compute documentElement.scrollHeight correctly
// Postponing to just next cycle allow us to fix this.
setTimeout(() => {
isWindowLoadComplete = true;
informAboutScroll();
}, 100);
return;
}
if (!embedStore.windowLoadEventFired) {
sdkActionManager?.fire("__windowLoadComplete", {});
}
embedStore.windowLoadEventFired = true;
// Use the dimensions of main element as in most places there is max-width restriction on it and we just want to show the main content.
// It avoids the unwanted padding outside main tag.
const mainElement =
document.getElementsByClassName("main")[0] ||
document.getElementsByTagName("main")[0] ||
document.documentElement;
const documentScrollHeight = document.documentElement.scrollHeight;
const documentScrollWidth = document.documentElement.scrollWidth;
if (!(mainElement instanceof HTMLElement)) {
throw new Error("Main element should be an HTMLElement");
}
const mainElementStyles = getComputedStyle(mainElement);
// Use, .height as that gives more accurate value in floating point. Also, do a ceil on the total sum so that whatever happens there is enough iframe size to avoid scroll.
const contentHeight = Math.ceil(
parseFloat(mainElementStyles.height) +
parseFloat(mainElementStyles.marginTop) +
parseFloat(mainElementStyles.marginBottom)
);
const contentWidth = Math.ceil(
parseFloat(mainElementStyles.width) +
parseFloat(mainElementStyles.marginLeft) +
parseFloat(mainElementStyles.marginRight)
);
// During first render let iframe tell parent that how much is the expected height to avoid scroll.
// Parent would set the same value as the height of iframe which would prevent scroll.
// On subsequent renders, consider html height as the height of the iframe. If we don't do this, then if iframe gets bigger in height, it would never shrink
const iframeHeight = isFirstTime ? documentScrollHeight : contentHeight;
const iframeWidth = isFirstTime ? documentScrollWidth : contentWidth;
if (!iframeHeight || !iframeWidth) {
runAsap(informAboutScroll);
return;
}
const isThereAChangeInDimensions = knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth;
if (isThereAChangeInDimensions || !embedStore.parentInformedAboutContentHeight) {
embedStore.parentInformedAboutContentHeight = true;
knownIframeHeight = iframeHeight;
knownIframeWidth = iframeWidth;
// FIXME: This event shouldn't be subscribable by the user. Only by the SDK.
sdkActionManager?.fire("__dimensionChanged", {
iframeHeight,
iframeWidth,
isFirstTime,
});
}
isFirstTime = false;
// Parent Counterpart would change the dimension of iframe and thus page's dimension would be impacted which is recursive.
// It should stop ideally by reaching a hiddenHeight value of 0.
// FIXME: If 0 can't be reached we need to just abandon our quest for perfect iframe and let scroll be there. Such case can be logged in the wild and fixed later on.
runAsap(informAboutScroll);
});
}
function main() {
if (!isBrowser) {
return;
@@ -659,7 +575,7 @@ function initializeAndSetupEmbed() {
const pageStatus = window.CalComPageStatus;
if (!pageStatus || pageStatus == "200") {
keepParentInformedAboutDimensionChanges();
keepParentInformedAboutDimensionChanges({ embedStore });
} else
sdkActionManager?.fire("linkFailed", {
code: pageStatus,
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { type EmbedStore } from "../lib/embedStore";
import { fakeCurrentDocumentUrl, takeBookerToSlotsLoadingState, takeBookerToReadyState } from "./test-utils";
beforeEach(() => {
@@ -13,15 +14,15 @@ afterEach(() => {
});
describe("isLinkReady", async () => {
let isLinkReady: typeof import("../lib/utils").isLinkReady;
let embedStore: typeof import("../lib/embedStore").embedStore;
let isLinkReady: (props: { embedStore: EmbedStore }) => boolean;
let embedStore: EmbedStore;
beforeEach(async () => {
({ isLinkReady } = await import("../lib/utils"));
({ embedStore } = await import("../lib/embedStore"));
// Reset embedStore state to ensure test isolation
embedStore.parentInformedAboutContentHeight = true;
embedStore.providedCorrectHeightToParent = true;
embedStore.renderState = null;
embedStore.connectVersion = 1;
});
@@ -72,11 +73,11 @@ describe("isLinkReady", async () => {
});
});
describe("when parent not informed about content height", () => {
describe("when parent not provided correct height", () => {
it("should return false regardless of other conditions", () => {
fakeCurrentDocumentUrl({ params: { "cal.embed.pageType": "user.event.booking.slots" } });
takeBookerToReadyState();
embedStore.parentInformedAboutContentHeight = false; // Not informed yet
embedStore.providedCorrectHeightToParent = false; // Not informed yet
const result = isLinkReady({ embedStore });
expect(result).toBe(false);
@@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { sdkActionManager } from "../../sdk-event";
import { embedStore } from "../lib/embedStore";
import { keepParentInformedAboutDimensionChanges } from "../lib/utils";
import { fakeCurrentDocumentUrl } from "./test-utils";
type DimensionEvent = {
isFirstTime: boolean;
height: number;
width: number;
};
type DOMTestData = {
documentElementScrollDimensions: { height: number; width: number };
mainElementDimensions: { height: number; width: number };
readyState: "complete" | "loading";
};
type DomEnvironment = DOMTestData & {
setReadyState: (readyState: "complete" | "loading") => void;
};
const RUN_ASAP_TICK_MS = 50;
const WINDOW_LOAD_COMPLETE_CONFIRMED_DELAY_MS = 100;
const createDOMEnvironment = (testData: DOMTestData) => {
Object.defineProperty(document, "readyState", {
writable: true,
configurable: true,
value: testData.readyState,
});
const mainElement = document.createElement("main");
mainElement.className = "main";
document.body.appendChild(mainElement);
Object.defineProperty(document.documentElement, "scrollHeight", {
writable: true,
configurable: true,
value: testData.documentElementScrollDimensions.height,
});
Object.defineProperty(document.documentElement, "scrollWidth", {
writable: true,
configurable: true,
value: testData.documentElementScrollDimensions.width,
});
const originalGetComputedStyle = window.getComputedStyle;
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
if (element.classList?.contains("main") || element.tagName === "MAIN") {
return {
height: `${testData.mainElementDimensions.height}px`,
width: `${testData.mainElementDimensions.width}px`,
marginTop: "0px",
marginBottom: "0px",
marginLeft: "0px",
marginRight: "0px",
} as CSSStyleDeclaration;
}
return originalGetComputedStyle(element);
});
return {
...testData,
setReadyState: (readyState: "complete" | "loading") => {
Object.defineProperty(document, "readyState", {
writable: true,
configurable: true,
value: readyState,
});
},
};
};
const resetEmbedState = () => {
embedStore.providedCorrectHeightToParent = false;
embedStore.windowLoadEventFired = false;
fakeCurrentDocumentUrl({ params: {} });
};
const createDimensionTracker = () => {
const events: DimensionEvent[] = [];
sdkActionManager?.on("__dimensionChanged", (e) => {
events.push({
isFirstTime: e.detail.data.isFirstTime,
height: e.detail.data.iframeHeight,
width: e.detail.data.iframeWidth,
});
});
return events;
};
const expectCorrectHeightNotProvided = () => {
expect(embedStore.providedCorrectHeightToParent).toBe(false);
};
const expectCorrectHeightProvided = () => {
expect(embedStore.providedCorrectHeightToParent).toBe(true);
};
const expectFirstPassEvent = (
event: DimensionEvent | undefined,
{
documentElementScrollHeight,
documentElementScrollWidth,
}: {
documentElementScrollHeight: number;
documentElementScrollWidth: number;
}
) => {
expect(event).toBeDefined();
expect(event?.isFirstTime).toBe(true);
expect(event?.height).toBe(documentElementScrollHeight);
expect(event?.width).toBe(documentElementScrollWidth);
};
const expectSecondPassEvent = (
event: DimensionEvent | undefined,
{
mainElementHeight,
mainElementWidth,
}: {
mainElementHeight: number;
mainElementWidth: number;
}
) => {
expect(event).toBeDefined();
expect(event?.isFirstTime).toBe(false);
expect(event?.height).toBe(mainElementHeight);
expect(event?.width).toBe(mainElementWidth);
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
document.body.innerHTML = "";
});
describe("keepParentInformedAboutDimensionChanges", () => {
let domEnvironment: DomEnvironment;
beforeEach(() => {
domEnvironment = createDOMEnvironment({
documentElementScrollDimensions: { height: 1000, width: 800 },
mainElementDimensions: { height: 500, width: 400 },
readyState: "complete",
});
resetEmbedState();
});
describe("when iframe dimensions are calculated", () => {
it("should not mark height as provided during initial dimension pass", async () => {
const dimensionEvents = createDimensionTracker();
keepParentInformedAboutDimensionChanges({ embedStore });
await vi.advanceTimersByTimeAsync(RUN_ASAP_TICK_MS + WINDOW_LOAD_COMPLETE_CONFIRMED_DELAY_MS);
expectCorrectHeightNotProvided();
const firstEvent = dimensionEvents[0];
expectFirstPassEvent(firstEvent, {
documentElementScrollHeight: domEnvironment.documentElementScrollDimensions.height,
documentElementScrollWidth: domEnvironment.documentElementScrollDimensions.width,
});
});
it("should mark height as provided after second dimension pass completes", async () => {
const dimensionEvents = createDimensionTracker();
keepParentInformedAboutDimensionChanges({ embedStore });
await vi.advanceTimersByTimeAsync(RUN_ASAP_TICK_MS + WINDOW_LOAD_COMPLETE_CONFIRMED_DELAY_MS);
expectCorrectHeightNotProvided();
await vi.advanceTimersByTimeAsync(RUN_ASAP_TICK_MS);
expectCorrectHeightProvided();
const secondEvent = dimensionEvents[1];
expectSecondPassEvent(secondEvent, {
mainElementHeight: domEnvironment.mainElementDimensions.height,
mainElementWidth: domEnvironment.mainElementDimensions.width,
});
});
});
describe("when window load state changes", () => {
it("should wait for window load completion before calculating dimensions", async () => {
domEnvironment = createDOMEnvironment({
documentElementScrollDimensions: { height: 1000, width: 800 },
mainElementDimensions: { height: 500, width: 400 },
readyState: "loading",
});
const dimensionEvents = createDimensionTracker();
keepParentInformedAboutDimensionChanges({ embedStore });
await vi.advanceTimersByTimeAsync(RUN_ASAP_TICK_MS + WINDOW_LOAD_COMPLETE_CONFIRMED_DELAY_MS);
expect(dimensionEvents.length).toBe(0);
expectCorrectHeightNotProvided();
domEnvironment.setReadyState("complete");
await vi.advanceTimersByTimeAsync(RUN_ASAP_TICK_MS + WINDOW_LOAD_COMPLETE_CONFIRMED_DELAY_MS);
expect(dimensionEvents.length).toBe(1);
});
it("should fire window load complete event on first pass", async () => {
domEnvironment = createDOMEnvironment({
documentElementScrollDimensions: { height: 1000, width: 800 },
mainElementDimensions: { height: 500, width: 400 },
readyState: "complete",
});
const windowLoadEvents: unknown[] = [];
sdkActionManager?.on("__windowLoadComplete", () => {
windowLoadEvents.push({});
});
keepParentInformedAboutDimensionChanges({ embedStore });
await vi.advanceTimersByTimeAsync(RUN_ASAP_TICK_MS + WINDOW_LOAD_COMPLETE_CONFIRMED_DELAY_MS);
expect(windowLoadEvents.length).toBe(1);
expect(embedStore.windowLoadEventFired).toBe(true);
});
});
});
@@ -106,7 +106,7 @@ export const embedStore = {
reactStylesStateSetters: {} as Record<keyof EmbedStyles, SetStyles>,
reactNonStylesStateSetters: {} as Record<keyof EmbedNonStylesConfig, setNonStylesConfig>,
// Embed can show itself only after this is set to true
parentInformedAboutContentHeight: false,
providedCorrectHeightToParent: false,
windowLoadEventFired: false,
setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined,
theme: undefined as UiConfig["theme"],
@@ -116,3 +116,5 @@ export const embedStore = {
*/
setUiConfig: [] as ((arg0: UiConfig) => void)[],
};
export type EmbedStore = typeof embedStore;
@@ -1,3 +1,6 @@
import { sdkActionManager } from "../../sdk-event";
import { type EmbedStore } from "../lib/embedStore";
export function runAsap(fn: (...arg: unknown[]) => void) {
// We don't use rAF because it runs slower in Safari plus doesn't run if the iframe is hidden sometimes
return setTimeout(fn, 50);
@@ -22,8 +25,8 @@ function isSkeletonSupportedPageType() {
/**
* It is important to be able to check realtime(instead of storing isLinkReady as a variable) if the link is ready, because there is a possibility that booker might have moved to non-ready state from ready state
*/
export function isLinkReady({ embedStore }: { embedStore: typeof import("./embedStore").embedStore }) {
if (!embedStore.parentInformedAboutContentHeight) {
export function isLinkReady({ embedStore }: { embedStore: EmbedStore }) {
if (!embedStore.providedCorrectHeightToParent) {
return false;
}
@@ -40,6 +43,107 @@ export function isLinkReady({ embedStore }: { embedStore: typeof import("./embed
return true;
}
/**
* This function is called once the iframe loads.
* It isn't called on "connect"
*
* Two-pass dimension strategy:
* 1. Initial pass: Uses document scroll dimensions to provide maximum space and avoid affecting layout
* 2. Second pass: Updates with correct dimensions of main container after parent adjusts iframe
*
* The correct iframe height is only available after the second pass, so we mark providedCorrectHeightToParent=true
* when the second pass runs (regardless of whether dimensions changed or not).
*/
export function keepParentInformedAboutDimensionChanges({ embedStore }: { embedStore: EmbedStore }) {
let knownIframeHeight: number | null = null;
let knownIframeWidth: number | null = null;
let isInitialDimensionPass = true;
let isWindowLoadComplete = false;
runAsap(function informAboutScroll() {
if (document.readyState !== "complete") {
// Wait for window to load to correctly calculate the initial scroll height.
runAsap(informAboutScroll);
return;
}
if (!isWindowLoadComplete) {
// On Safari, even though document.readyState is complete, still the page is not rendered and we can't compute documentElement.scrollHeight correctly
// Postponing to just next cycle allow us to fix this.
setTimeout(() => {
isWindowLoadComplete = true;
informAboutScroll();
}, 100);
return;
}
if (!embedStore.windowLoadEventFired) {
sdkActionManager?.fire("__windowLoadComplete", {});
}
embedStore.windowLoadEventFired = true;
// Use the dimensions of main element as in most places there is max-width restriction on it and we just want to show the main content.
// It avoids the unwanted padding outside main tag.
const mainElement =
document.getElementsByClassName("main")[0] ||
document.getElementsByTagName("main")[0] ||
document.documentElement;
const documentScrollHeight = document.documentElement.scrollHeight;
const documentScrollWidth = document.documentElement.scrollWidth;
if (!(mainElement instanceof HTMLElement)) {
throw new Error("Main element should be an HTMLElement");
}
const mainElementStyles = getComputedStyle(mainElement);
// Use, .height as that gives more accurate value in floating point. Also, do a ceil on the total sum so that whatever happens there is enough iframe size to avoid scroll.
const contentHeight = Math.ceil(
parseFloat(mainElementStyles.height) +
parseFloat(mainElementStyles.marginTop) +
parseFloat(mainElementStyles.marginBottom)
);
const contentWidth = Math.ceil(
parseFloat(mainElementStyles.width) +
parseFloat(mainElementStyles.marginLeft) +
parseFloat(mainElementStyles.marginRight)
);
// During first render let iframe tell parent that how much is the expected height to avoid scroll.
// Parent would set the same value as the height of iframe which would prevent scroll.
// On subsequent renders, consider html height as the height of the iframe. If we don't do this, then if iframe gets bigger in height, it would never shrink
const iframeHeight = isInitialDimensionPass ? documentScrollHeight : contentHeight;
const iframeWidth = isInitialDimensionPass ? documentScrollWidth : contentWidth;
if (!iframeHeight || !iframeWidth) {
runAsap(informAboutScroll);
return;
}
const isThereAChangeInDimensions = knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth;
if (isThereAChangeInDimensions || !embedStore.providedCorrectHeightToParent) {
knownIframeHeight = iframeHeight;
knownIframeWidth = iframeWidth;
// FIXME: This event shouldn't be subscribable by the user. Only by the SDK.
sdkActionManager?.fire("__dimensionChanged", {
iframeHeight,
iframeWidth,
isFirstTime: isInitialDimensionPass,
});
}
// After the initial pass, we've provided the correct height to parent
// This happens on second pass regardless of whether dimensions changed
if (!isInitialDimensionPass) {
embedStore.providedCorrectHeightToParent = true;
}
isInitialDimensionPass = false;
// Parent Counterpart would change the dimension of iframe and thus page's dimension would be impacted which is recursive.
// It should stop ideally by reaching a hiddenHeight value of 0.
// FIXME: If 0 can't be reached we need to just abandon our quest for perfect iframe and let scroll be there. Such case can be logged in the wild and fixed later on.
runAsap(informAboutScroll);
});
}
/**
* Moves the queuedFormResponse to the routingFormResponse record to mark it as an actual response now.
*/