fix: preserve UI config across iframe resets for element-click embeds (#27363)

* fix: preserve UI config across iframe resets for element-click embeds

For element-click embeds, ui() is called before the iframe exists, and
iframeReset() clears the queue when the modal is opened. This causes UI
config like hideEventTypeDetails, cssVarsPerTheme, and styles to be lost.

This fix stores UI config in a persistent property that survives iframe
resets and applies it when the iframe becomes ready.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add assertion to verify hideEventTypeDetails config is applied in element-click embed

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Add mergeUiConfig function and related tests for UI configuration merging

- Introduced mergeUiConfig function to handle merging of UI configuration, specifically for cssVarsPerTheme at theme level.
- Added comprehensive tests for mergeUiConfig covering various scenarios including merging themes, handling missing properties, and preserving existing configuration values.
- Updated utils.test.ts to include tests for the new function and ensure correct behavior in different merging situations.

* Fixes

* Remove process import as it breaks embed.js

* Simplify

* fix name

* fix name

* fix: use forEach instead of for...of for URLSearchParams iteration

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
Joe Au-Yeung
2026-01-29 08:18:53 -03:00
committed by GitHub
co-authored by hariom@cal.com Devin AI
parent 3b2e3351c4
commit ca5d931165
2 changed files with 48 additions and 31 deletions
@@ -321,6 +321,13 @@ test.describe("Popup Tests", () => {
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: "/free/30min",
});
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
// Verify that event type details are hidden (hideEventTypeDetails: true was set via ui() call)
await expect(embedIframe.locator('[data-testid="event-meta"]')).toBeHidden();
});
});
+41 -31
View File
@@ -1,33 +1,35 @@
/// <reference types="../env" />
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
import {
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS,
} from "./constants";
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
import css from "./embed.css?inline";
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { getScrollableAncestor } from "./lib/domUtils";
import { getScrollByDistanceHandler } from "./lib/eventHandlers/scrollByDistanceEventHandler";
import {
buildConfigWithPrerenderRelatedFields,
fromEntriesWithDuplicateKeys,
isRouterPath,
generateDataAttributes,
getConfigProp,
isRouterPath,
isSameBookingLink,
buildConfigWithPrerenderRelatedFields,
} from "./lib/utils";
import { SdkActionManager } from "./sdk-action-manager";
import { ModalBox } from "./ModalBox/ModalBox";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import { SdkActionManager } from "./sdk-action-manager";
import tailwindCss from "./tailwindCss";
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig, ModalPrerenderOptions } from "./types";
import type { EmbedPageType, ModalPrerenderOptions, PrefillAndIframeAttrsConfig, UiConfig } from "./types";
import { getMaxHeightForModal } from "./ui-utils";
// Exporting for consumption by @calcom/embed-core user
export type { EmbedEvent } from "./sdk-action-manager";
export type { PrefillAndIframeAttrsConfig } from "./types";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Rest<T extends any[] | undefined> = T extends [any, ...infer U] ? U : never;
export type Message = {
@@ -177,8 +179,8 @@ type SingleInstructionMap = {
off: ["off", allPossibleCallbacksAndActions];
} & {
[K in Exclude<keyof CalApi, "on" | "off">]: CalApi[K] extends (...args: never[]) => void
? [K, ...Parameters<CalApi[K]>]
: never;
? [K, ...Parameters<CalApi[K]>]
: never;
};
type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
@@ -231,6 +233,11 @@ export class Cal {
api: CalApi;
/**
* Commands for which the queue persists across iframe resets
*/
commandsPersistAcrossIframeResets: DoInIframeArg['method'][] = ["ui"];
isPrerendering?: boolean;
static actionsManagers: Record<Namespace, SdkActionManager>;
@@ -361,14 +368,13 @@ export class Cal {
}
// Merge searchParams from config onto the URL which might have query params already
// @ts-ignore TS2802: URLSearchParams iteration requires downlevelIteration
for (const [key, value] of searchParams) {
searchParams.forEach((value, key) => {
urlInstance.searchParams.append(key, value);
}
});
// Very Important:Reset iframe ready flag and clear queue, as iframe might load a fresh URL and we need to check when it is ready.
// Very Important: Reset iframe ready flag and clear queue, as iframe might load a fresh URL and we need to check correctly when it is ready.
this.iframeReset();
if (iframe.src === urlInstance.toString()) {
// Ensure reload occurs even if the url is same - Though browser normally does it, but would be better to ensure it
// This param has no other purpose except to ensure forced reload.
@@ -408,7 +414,10 @@ export class Cal {
iframeReset() {
this.iframeReady = false;
this.iframeDoQueue = [];
// Only keep UI related instructions in the queue, as we want to ensure that newly loaded iframe has the same UI configuration applied automatically
this.iframeDoQueue = this.iframeDoQueue.filter((doInIframeArg) =>
this.commandsPersistAcrossIframeResets.includes(doInIframeArg.method)
);
}
constructor(namespace: string, q: Queue) {
@@ -604,7 +613,7 @@ export class Cal {
: 0;
const crossedReloadThreshold = previousEmbedRenderStartTime
? timeSinceLastRender >
(prerenderOptions?.iframeForceReloadThresholdMs ?? EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS)
(prerenderOptions?.iframeForceReloadThresholdMs ?? EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS)
: false;
const areSlotsStale = previousEmbedRenderStartTime
@@ -862,7 +871,7 @@ class CalApi {
CalApi.initializedNamespaces.push(this.cal.namespace);
const { calOrigin: calOrigin, origin: origin, ...restConfig } = config;
const { calOrigin, origin, ...restConfig } = config;
this.cal.__config.calOrigin = calOrigin || origin || this.cal.__config.calOrigin;
@@ -950,10 +959,10 @@ class CalApi {
template.innerHTML = `<cal-inline
${generateDataAttributes({
pageType,
theme,
layout,
})}
pageType,
theme,
layout,
})}
style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap;width:100%">
</cal-inline>
<style>.cal-inline-container::-webkit-scrollbar{display:none}.cal-inline-container{scrollbar-width:none}</style>`;
@@ -1187,7 +1196,7 @@ class CalApi {
config: enrichedConfig,
});
// Send reloadInitiated message to iframe so it can track it
// Send it after loadInIframe so that new iframe can process it.
// Send it after loadInIframe so that new iframe can process it.
this.cal.doInIframe({ method: "__reloadInitiated", arg: {} });
} else if (actionToTake === "connect" || actionToTake === "connect-no-slots-fetch") {
const paramsToAdd = fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries());
@@ -1196,8 +1205,8 @@ class CalApi {
...enrichedConfig,
...(actionToTake === "connect-no-slots-fetch"
? {
"cal.embed.noSlotsFetchOnConnect": "true",
}
"cal.embed.noSlotsFetchOnConnect": "true",
}
: {}),
},
params: paramsToAdd,
@@ -1239,10 +1248,10 @@ class CalApi {
template.innerHTML = `<cal-modal-box
${generateDataAttributes({
pageType,
theme,
layout,
})}
pageType,
theme,
layout,
})}
uid="${uid}">
</cal-modal-box>`;
this.cal.modalBox = template.content.children[0];
@@ -1303,9 +1312,9 @@ class CalApi {
/**
* Closes modal-based embeds programmatically.
*
*
* @throws {Error} If called on an inline embed (only works for modal-based embeds)
*
*
* @example
* ```javascript
* // Close the modal after a successful booking
@@ -1487,6 +1496,7 @@ class CalApi {
},
});
this.cal.doInIframe({ method: "ui", arg: uiConfig });
}
}