fix: Avoid prerender duplicate calls for same calLink that could arise with faulty implementation (#21982)

* fix: Avoid prerender duplicate calls

* Update README

* Ensure "noAction" action occurs if nothing changes

* fix bug

* Introduce connect method for reliable re-use

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Hariom BalharaandAlex van Andel authored and GitHub committed 2025-07-03 10:51:13 +01:00
1 parent f8a1bd7b94
commit 9f6c05f389
6 files changed
+454 -727

No files matched your search

+52 -13
View File
@@ -166,8 +166,8 @@ For example, if your page is `https.example.com/contact`, you can enable logging
#### Routing Prerendering System
The prerendering system optimizes the initial load:
```typescript
Cal.prerender({
calLink: "organization/event-type",
Cal("prerender", {
calLink,
type: "modal"
});
```
@@ -178,24 +178,63 @@ Key aspects:
- Loads the booking page but doesn't send the slots availability request
- Tries to reuse whenever it makes sense and do a fresh load otherwise
Iframe Reuse and Reload Conditions. There could be three situations:
Modal's Iframe Reuse and Reload Conditions. There could be four situations:
1. Reuse
2. Reuse the iframe but refetch the slots
3. Do a fresh load in iframe
1. Show modal as is. No connect and no requests happen - Corresponding action is "noAction"
2. Connect but don't fetch the slots- Corresponding action is "connect-no-slots-fetch"
3. Connect and fetch the slots - Corresponding action is "connect"
4. Do a fresh load in iframe. Corresponding action is "fullReload"
- **Reuse**:
- Modal opens when
- Modal is not in a failed state
- config, params are same as the last time
- No threshold violations
- **Show modal as is**:
- Modal is not in a failed state
- config, params are same as the last time
- No time threshold violations
- **Reuse the iframe but refetch the slots**:
- **Connect but don't fetch the slots**:
- Only embed `config` changes (handled via "connect" flow)
- Query query params changes (handled via "connect" flow)
- Crossed slots stale time threshold (EMBED_MODAL_IFRAME_SLOT_STALE_TIME)
- **Fresh Reload Conditions**:
- **Connect and fetch the slots**:
- Slots are stale
- Crossed slots stale time threshold (EMBED_MODAL_IFRAME_SLOT_STALE_TIME)
- **Do a fresh load in iframe**:
- Different path being loaded(i.e. /pro vs /free)
- Modal is in a failed state
- Time since last render exceeds EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS
#### Prerendering headless router
**Without namespace:**
Prerender when there are high chances of user clicking the CTA.
```js
Cal('prerender', {
calLink: "router?formId=123&ONLY_THOSE_FIELDS_THAT_ARE_REQUIRED_BY_ROUTING_RULES_SHOULD_BE_PRESENT_HERE",
// Prerender right now works only with "modal", so 'element click' embed is able to reuse this prerendered iframe
type: "modal",
// Shows skeleton loader for a Team Event's booking slots page
pageType: "team.event.booking.slots"
});
```
Using the prerendered iframe with a CTA:
```js
<button data-cal-link="router?formId=123&ALL_FIELDS_HERE">Demo</button>
```
**With namespace:**
Prerender when there are high changes of user clicking the CTA.
```js
Cal.ns.myNamespace('prerender', {
calLink: "router?formId=123&ONLY_THOSE_FIELDS_THAT_ARE_REQUIRED_BY_ROUTING_RULES_SHOULD_BE_PRESENT_HERE",
// Prerender right now works only with "modal", so 'element click' embed is able to reuse this prerendered iframe
type: "modal"
// Shows skeleton loader for a Team Event's booking slots page
pageType: "team.event.booking.slots"
});
```
Using the prerendered iframe with a CTA:
```js
<button data-cal-namespace="myNamespace" data-cal-link="router?formId=123&ALL_FIELDS_HERE">Demo</button>
```
@@ -4,6 +4,8 @@ export const EMBED_MODAL_IFRAME_SLOT_STALE_TIME = 60 * 1000; // 1 minute
// Keep it high by default to avoid impact on loading time as it would cause a full reload of the iframe user can configure it via options.iframeForceReloadThresholdMs
// Also full reload means that response would be resubmitted.
export const EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
// Ensures that prerender instruction would never re-prerender within one minute as long as the link is the same.
export const EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS = 1 * 60 * 1000; // 1 minute
if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS) {
throw new Error(
+126 -534
View File
@@ -5,22 +5,13 @@ import { describe, it, expect, beforeEach, vi, beforeAll } from "vitest";
import {
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS,
} from "./constants";
import { submitResponseAndGetRoutingResult } from "./utils";
vi.mock("./tailwindCss", () => ({
default: "mockedTailwindCss",
}));
vi.mock("./utils", async () => {
const actual = (await vi.importActual("./utils")) as any;
return {
...actual,
submitResponseAndGetRoutingResult: vi.fn(),
};
});
type ExpectedModalBoxAttrs = {
theme: string;
layout: string;
@@ -335,6 +326,7 @@ describe("Cal", () => {
__prerender: true,
};
calInstance.api.modal(modalArg);
expect(calInstance.isPrerendering).toBe(true);
const modalBox = expectCalModalBoxToBeInDocument({
state: "prerendering",
@@ -399,11 +391,82 @@ describe("Cal", () => {
});
});
it("Accidental repeat prerender prevention", async () => {
const modalArg = {
...baseModalArgs,
calLink: "router?form=123&email=john@example.com",
};
const { status: status1 } = await calInstance.api.modal({
...modalArg,
__prerender: true,
});
expect(status1).toBe("created");
const embedRenderStartTime = calInstance.embedRenderStartTime;
const { status: status2 } = await calInstance.api.modal({
...modalArg,
__prerender: true,
});
expect(status2).toBe("prerender-prevented");
// embedRenderStartTime should not be updated prerender did not happen
expect(calInstance.embedRenderStartTime).toBe(embedRenderStartTime);
});
it("should allow repeat prerender after threshold time has passed", async () => {
vi.useFakeTimers();
const modalArg = {
...baseModalArgs,
calLink: "router?form=123&email=john@example.com",
};
// First prerender
const { status: status1 } = await calInstance.api.modal({
...modalArg,
__prerender: true,
});
expect(status1).toBe("created");
// Mock time passage beyond threshold
vi.advanceTimersByTime(EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS + 1000);
// Second prerender after threshold - should be ALLOWED
const { status: status2 } = await calInstance.api.modal({
...modalArg,
__prerender: true,
});
expect(status2).toBe("created");
vi.useRealTimers();
});
it("Prerender with non-headless router link and then CTA click with headless router is not allowed", async () => {
const modalArg = {
...baseModalArgs,
calLink: "john-doe/meeting",
};
const { status: status1 } = await calInstance.api.modal({
...modalArg,
__prerender: true,
});
expect(status1).toBe("created");
await expect(
calInstance.api.modal({
...modalArg,
calLink: "router?form=123&email=john@example.com",
})
).rejects.toThrow("`prerender` instruction should have been fired with headless router path");
});
describe("Modal State Transitions", () => {
it(`should handle prerender -> open(with prefill) -> reopen scenario`, () => {
it(`should handle prerender -> open(with prefill) -> reopen(with re-submission) scenario`, () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
calLink: "router?form=FORM_ID&routingField1=value1&routingField2=value2",
};
const { modalBoxUid, expectedConfig } = (function prerender(): {
@@ -412,6 +475,7 @@ describe("Cal", () => {
} {
log("Prerendering the modal");
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
expect(calInstance.isPrerendering).toBe(true);
const expectedConfig = {
...modalArg.config,
embedType: "modal",
@@ -425,11 +489,14 @@ describe("Cal", () => {
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
pathname: `${new URL(modalArg.calLink, "https://baseurl.example").pathname}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
form: "FORM_ID",
"cal.queueFormResponse": "true",
routingField1: "value1",
routingField2: "value2",
}),
origin: null,
},
@@ -439,138 +506,14 @@ describe("Cal", () => {
return { modalBoxUid, expectedConfig };
})();
const { modalArgWithPrefilledConfig } = (function prefill() {
log("Opening the modal with prefill config");
const modalArgWithPrefilledConfig = {
...modalArg,
config: { ...modalArg.config, name: "John Doe", email: "john@example.com" },
};
// Second modal call with additional config value for prefilling
calInstance.api.modal(modalArgWithPrefilledConfig);
const expectedConfigAfterPrefilling = {
...expectedConfig,
name: modalArgWithPrefilledConfig.config.name,
email: modalArgWithPrefilledConfig.config.email,
};
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
// Expect the modal to go to loading state - which starts the loader animation
state: "loading",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
uid: modalBoxUid,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
// It remains because internally we remove prerender=true in iframe, as it is connect mode
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
// This would update the iframe document URL through embed-iframe.
expect(calInstance.doInIframe).toHaveBeenCalledWith({
method: "connect",
arg: {
config: {
...expectedConfigAfterPrefilling,
},
params: {},
},
});
vi.mocked(calInstance.doInIframe).mockClear();
return { modalArgWithPrefilledConfig };
})();
(function reopen() {
log("Reopening the same modal without any changes");
calInstance.api.modal(modalArgWithPrefilledConfig);
// Connect won't happen again
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
})
);
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
// Expect the modal to just go to "reopened" state
state: "reopened",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
uid: modalBoxUid,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
// It remains because we are just reopening the same modal
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
})();
});
it(`should handle prerender -> open(with prefill) -> error -> loading scenario`, () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
};
const { modalBoxUid, modalBox, expectedConfig } = (function prerender() {
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
const expectedConfig = {
...modalArg.config,
embedType: "modal",
};
const { modalBox } = expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "prerendering",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
const modalBoxUid = modalBox.getAttribute("uid");
return { modalBoxUid, modalBox, expectedConfig };
})();
const { modalArgWithPrefilledConfig, expectedConfigAfterPrefilling } = (function prefill() {
log("Opening the modal with prefill config");
const modalArgWithPrefilledConfig = {
...modalArg,
config: { ...modalArg.config, name: "John Doe", email: "john@example.com" },
};
// Second modal call with additional config value for prefilling
calInstance.api.modal({ ...buildModalArg(modalArgWithPrefilledConfig) });
calInstance.api.modal(modalArgWithPrefilledConfig);
const expectedConfigAfterPrefilling = {
...expectedConfig,
@@ -588,24 +531,33 @@ describe("Cal", () => {
uid: modalBoxUid,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
pathname: `${new URL(modalArg.calLink, "https://baseurl.example").pathname}`,
searchParams: new URLSearchParams({
...expectedConfig,
// It remains because internally we remove prerender=true in iframe - through connect flow
// It remains because internally we remove prerender=true in iframe, as it is connect mode
prerender: "true",
"cal.skipSlotsFetch": "true",
form: "FORM_ID",
"cal.queueFormResponse": "true",
routingField1: "value1",
routingField2: "value2",
}),
origin: null,
},
});
// Right now we allow re-submission of the same form data, for which we need to connect again
expect(calInstance.doInIframe).toHaveBeenCalledWith({
method: "connect",
arg: {
config: {
"cal.embed.noSlotsFetchOnConnect": "true",
...expectedConfigAfterPrefilling,
},
params: {},
params: {
routingField1: "value1",
routingField2: "value2",
form: "FORM_ID",
},
},
});
@@ -613,412 +565,52 @@ describe("Cal", () => {
return { modalArgWithPrefilledConfig, expectedConfigAfterPrefilling };
})();
log("Simulating an error state");
modalBox.setAttribute("state", "failed");
(function reopen() {
log("Reopening the same modal but it is in error state, so full page load would be initiated");
log("Reopening the same modal without any changes");
calInstance.api.modal(modalArgWithPrefilledConfig);
expect(calInstance.isPrerendering).toBe(false);
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
})
);
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
// Expect the modal to just go to "reopened" state
state: "loading",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
uid: modalBoxUid,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfigAfterPrefilling,
}),
origin: null,
},
});
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
})();
});
it("should handle prerender(booking link) -> headless router submission -> reuse booking link ", async () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
calLink: "sales/demo",
};
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
log("Prerendering the modal");
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
const expectedConfig = {
...modalArg.config,
embedType: "modal",
};
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "prerendering",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
return { expectedConfig };
})();
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
return Promise.resolve({
// Redirecting to the booking link that is already prerendered
redirect: `${process.env.EMBED_PUBLIC_WEBAPP_URL}/${modalArg.calLink}`,
});
});
await (async function headlessRouterSubmission() {
log("Submitting the routing form headless");
const config = expectedConfig;
const configWithPrefilledValues = {
...config,
name: "John Doe",
email: "john@example.com",
};
const modalOpenPromise = calInstance.api.modal({
...modalArg,
calLink: "router?form=FORM_ID",
config: configWithPrefilledValues,
});
// State must immediately change to loading, so that loader is shown
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
expect(calModalBox.getAttribute("state")).toBe("loading");
await modalOpenPromise;
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "loading",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...config,
// It remains because internally we remove prerender=true in iframe as it is connect mode
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
// Now check that we connected with the right config
// Connect won't happen again
expect(calInstance.doInIframe).toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
arg: {
config: {
...configWithPrefilledValues,
"cal.embed.noSlotsFetchOnConnect": "true",
embedType: "modal",
...expectedConfigAfterPrefilling,
},
params: {
routingField1: "value1",
routingField2: "value2",
form: "FORM_ID",
},
params: {},
},
})
);
})();
});
it("should handle prerender(booking link) -> headless router submission -> different booking link redirect -> fresh page load", async () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
calLink: "sales/demo",
};
// expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
// expectedModalBoxAttrs: {
// // Expect the modal to just go to "reopened" state
// state: "reopened",
// theme: modalArg.config.theme,
// layout: modalArg.config.layout,
// pageType: null,
// uid: modalBoxUid,
// },
// expectedIframeUrlObject: {
// pathname: `/${modalArg.calLink}`,
// searchParams: new URLSearchParams({
// ...expectedConfig,
// // It remains because we are just reopening the same modal
// prerender: "true",
// "cal.skipSlotsFetch": "true",
// }),
// origin: null,
// },
// });
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
log("Prerendering the modal");
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
const expectedConfig = {
...modalArg.config,
embedType: "modal",
};
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "prerendering",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
return { expectedConfig };
})();
const headlessRouterRedirectUrlWithPathOnly = `/something_else_than_prerendered_link`;
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
return Promise.resolve({
// Redirecting to the booking link that is already prerendered
redirect: `${process.env.EMBED_PUBLIC_WEBAPP_URL}${headlessRouterRedirectUrlWithPathOnly}?routingFormResponseId=xyz`,
});
});
await (async function headlessRouterSubmission() {
log("Submitting the modal");
const config = expectedConfig;
const configWithPrefilledValues = {
...config,
name: "John Doe",
email: "john@example.com",
};
const configWithPrefilledValuesAndHeadlessRouterRedirectParams = {
...configWithPrefilledValues,
routingFormResponseId: "xyz",
};
const modalOpenPromise = calInstance.api.modal({
...modalArg,
calLink: "router?form=FORM_ID",
config: configWithPrefilledValues,
});
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
expect(calModalBox.getAttribute("state")).toBe("loading");
await modalOpenPromise;
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "loading",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: headlessRouterRedirectUrlWithPathOnly,
searchParams: new URLSearchParams({
...configWithPrefilledValuesAndHeadlessRouterRedirectParams,
}),
origin: null,
},
});
// Now check that we connected with the right config
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
})
);
})();
});
it("should handle prerender(booking link) -> headless router submission -> Message ", async () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
calLink: "sales/demo",
};
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
log("Prerendering the modal");
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
const expectedConfig = {
...modalArg.config,
embedType: "modal",
};
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "prerendering",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
return { expectedConfig };
})();
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
return Promise.resolve({
status: "success",
// Redirecting to the booking link that is already prerendered
message: "Fallback Route Message",
});
});
await (async function headlessRouterSubmission() {
log("Submitting the modal");
const config = expectedConfig;
const configWithPrefilledValues = {
...config,
name: "John Doe",
email: "john@example.com",
};
const modalOpenPromise = calInstance.api.modal({
...modalArg,
calLink: "router?form=FORM_ID",
config: configWithPrefilledValues,
});
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
expect(calModalBox.getAttribute("state")).toBe("loading");
await modalOpenPromise;
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "has-message",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
// Now check that we connected with the right config
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
})
);
expect(calModalBox.getAttribute("data-message")).toBe("Fallback Route Message");
})();
});
it("should handle prerender(booking link) -> headless router submission -> Error Message", async () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
calLink: "sales/demo",
};
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
log("Prerendering the modal");
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
const expectedConfig = {
...modalArg.config,
embedType: "modal",
};
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "prerendering",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
return { expectedConfig };
})();
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
return Promise.resolve({
error: "Fallback Route Error",
});
});
await (async function headlessRouterSubmission() {
log("Submitting the modal");
const config = expectedConfig;
const configWithPrefilledValues = {
...config,
name: "John Doe",
email: "john@example.com",
};
const modalOpenPromise = calInstance.api.modal({
...modalArg,
calLink: "router?form=FORM_ID",
config: configWithPrefilledValues,
});
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
expect(calModalBox.getAttribute("state")).toBe("loading");
await modalOpenPromise;
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "failed",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
// Now check that we connected with the right config
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
})
);
expect(calModalBox.getAttribute("data-error-code")).toBe("routerError");
expect(calModalBox.getAttribute("data-message")).toBe("Fallback Route Error");
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
})();
});
});
+239 -180
View File
@@ -6,21 +6,22 @@ import { addAppCssVars } from "./addAppCssVars";
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";
import { SdkActionManager } from "./sdk-action-manager";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import tailwindCss from "./tailwindCss";
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig } from "./types";
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig, ModalPrerenderOptions } from "./types";
import { getMaxHeightForModal } from "./ui-utils";
import {
fromEntriesWithDuplicateKeys,
isRouterPath,
submitResponseAndGetRoutingResult,
generateDataAttributes,
getConfigProp,
isSameBookingLink,
buildConfigWithPrerenderRelatedFields,
} from "./utils";
// Exporting for consumption by @calcom/embed-core user
@@ -51,12 +52,8 @@ type CalConfig = {
uiDebug?: boolean;
};
type ModalPrerenderOptions = {
slotsStaleTimeMs?: number;
iframeForceReloadThresholdMs?: number;
reuseFully?: boolean;
// Default value is true right now but it would change soon
backgroundSlotsFetch?: boolean;
type ModalBoxElement = Element & {
uid?: string;
};
type ModalStateData = {
@@ -221,7 +218,7 @@ export class Cal {
__config: CalConfig;
modalBox?: Element;
modalBox?: ModalBoxElement;
inlineEl?: Element;
@@ -688,89 +685,128 @@ export class Cal {
return new URL(`${urlObject.pathname}${urlObject.search}`, urlObject.origin);
}
async submitThroughHeadlessRouterInModal({
modal,
calLinkUrlObject,
stateData,
}: {
modal: { uid: string; element: Element; calOrigin: string | null };
calLinkUrlObject: URL;
stateData: ModalStateData;
}) {
const { uid: modalBoxUid, element: modalEl, calOrigin: _calOrigin } = modal;
const { embedConfig } = stateData;
// We record the updated params on dataset.calLink which allows us to determine if something actually changed since the last time we loaded
// This is used by getNextActionForModal
recordUpdatedParamsForIframe(params: Record<string, string | string[]>) {
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
const lastLoadedPathInIframe = lastLoadedUrlInIframeObject?.pathname ?? null;
const calConfig = this.getCalConfig();
const calOrigin = _calOrigin ?? calConfig.calOrigin;
const headlessRouterPageObject = calLinkUrlObject;
const result = await submitResponseAndGetRoutingResult({
headlessRouterPageUrl: headlessRouterPageObject.toString(),
});
log("Headless router result", result);
if ("redirect" in result) {
const routerRedirectUrl = new URL(result.redirect);
const paramsFromRedirect = fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries());
const newEmbedConfig = (this.embedConfig = {
...embedConfig,
...paramsFromRedirect,
});
const actionToTake = this.getNextActionForModal({
modal: { uid: modalBoxUid },
pathWithQueryToLoad: `${routerRedirectUrl.pathname}${routerRedirectUrl.search}`,
stateData: {
...stateData,
embedConfig: newEmbedConfig,
},
});
if (actionToTake === "fullReload") {
if (lastLoadedPathInIframe) {
console.error("Preloaded iframe couldn't be used", {
preloadedPath: lastLoadedPathInIframe,
newPath: routerRedirectUrl.pathname,
});
}
const pathWithoutStartingSlash = routerRedirectUrl.pathname.replace(/^\//, "");
this.loadInIframe({
calLink: pathWithoutStartingSlash,
calOrigin,
config: newEmbedConfig,
iframe: this.iframe as HTMLIFrameElement,
});
} else {
log("Reusing the same iframe for headless router", {
routerRedirectUrl: routerRedirectUrl.toString(),
lastLoadedPathInIframe,
});
// Connection Initiated
this.doInIframe({
method: "connect",
arg: {
config: newEmbedConfig,
params: fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries()),
},
});
}
} else if ("message" in result) {
log("Setting message in modal", {
message: result.message,
});
// TODO: We might need to sanitize the error message and error code before setting it
modalEl.setAttribute("data-message", result.message);
modalEl.setAttribute("state", "has-message");
} else if ("error" in result) {
log("Setting error in modal", {
error: result.error,
});
// We need to show this message in the modal
modalEl.setAttribute("data-error-code", "routerError");
// TODO: We might need to sanitize the error message and error code before setting it
modalEl.setAttribute("data-message", result.error);
modalEl.setAttribute("state", "failed");
if (!lastLoadedUrlInIframeObject || !this.iframe) {
return;
}
// Create a clone of the last loaded url object in iframe
const urlObject = new URL(lastLoadedUrlInIframeObject.toString());
for (const [key, value] of Object.entries(params)) {
urlObject.searchParams.set(key, value as string);
}
this.iframe.dataset.calLink = `${urlObject.pathname.replace(/^\//, "")}${urlObject.search}`;
}
canPrerenderLink({
calLink,
calOrigin,
previousEmbedRenderStartTime,
}: {
calLink: string;
calOrigin: string;
previousEmbedRenderStartTime: number | null;
}) {
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
// Trying to prerender a link that is already prerendered.
// Prevent unnecessary repeat prerenders
if (lastLoadedUrlInIframeObject?.toString() === new URL(calLink, calOrigin).toString()) {
const isThresholdCrossed = hasCrossedThreshold();
if (isThresholdCrossed) {
log("Threshold crossed, allowing repeat prerender");
return true;
}
log("Threshold not crossed, preventing repeat prerender");
return false;
}
return true;
function hasCrossedThreshold() {
if (!previousEmbedRenderStartTime) {
return false;
}
const timeDiff = Date.now() - previousEmbedRenderStartTime;
if (timeDiff >= EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS) {
return true;
}
return false;
}
}
prepareForPrerender({
calLink,
calOrigin,
previousEmbedRenderStartTime,
prerenderOptions,
isHeadlessRouterPath,
}: {
calLink: string;
calOrigin: string;
previousEmbedRenderStartTime: number | null;
prerenderOptions: ModalPrerenderOptions;
isHeadlessRouterPath: boolean;
}) {
if (!this.canPrerenderLink({ calLink, calOrigin, previousEmbedRenderStartTime })) {
return null;
}
if (this.modalBox) {
log("Destroying previous prerendered modalbox");
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
this.modalBox.remove();
}
const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
const backgroundSlotsFetch =
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
? DEFAULT_BACKGROUND_SLOTS_FETCH
: prerenderOptions.backgroundSlotsFetch;
const enrichedPrerenderOptions = { ...prerenderOptions, backgroundSlotsFetch };
// Persist prerender options here so that during CTA click we can use these.
this.api.prerenderOptions = enrichedPrerenderOptions;
this.isPrerendering = true;
return { enrichedPrerenderOptions };
}
setModalRenderStartVariables({
embedConfig,
embedRenderStartTime,
}: {
embedConfig: PrefillAndIframeAttrsConfig;
embedRenderStartTime: number;
}) {
this.embedConfig = embedConfig;
this.embedRenderStartTime = embedRenderStartTime;
}
getPreviousModalRenderStartVariables() {
return {
embedConfig: this.embedConfig,
embedRenderStartTime: this.embedRenderStartTime,
};
}
connect({
config,
params,
}: {
config: PrefillAndIframeAttrsConfig;
params: Record<string, string | string[]>;
}) {
// Update the iframe query params and record the change as well on the iframe for getNextActionForModal to use
this.recordUpdatedParamsForIframe(params);
this.doInIframe({
method: "connect",
arg: {
config,
params,
},
});
}
}
@@ -979,56 +1015,24 @@ class CalApi {
__prerender?: boolean;
prerenderOptions?: ModalPrerenderOptions;
}) {
const isPrerendering = !!__prerender;
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
const containerEl = document.body;
const calConfig = this.cal.getCalConfig();
// Clone so that it doesn't mutate the original config
config = { ...config };
// calOrigin could have been passed as empty string by the user
calOrigin = calOrigin || calConfig.calOrigin;
const calLinkUrlObject = new URL(calLink, calOrigin);
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
const { embedConfig: previousEmbedConfig, embedRenderStartTime: previousEmbedRenderStartTime } =
this.cal.getPreviousModalRenderStartVariables();
if (__prerender && this.cal.modalBox) {
log("Destroying previous prerendered modalbox");
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
this.cal.modalBox.remove();
}
// `this.modalUid` is set in non-preload case(Temporarily not being-set)
// `this.prerenderedModalUid` is set for a modal created through "prerender"
const uid = this.modalUid || this.prerenderedModalUid || String(Date.now()) || "0";
// Means whether there is already an attempt to use the prerendered modal
const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid);
const containerEl = document.body;
this.cal.isPrerendering = !!__prerender;
if (__prerender) {
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
const backgroundSlotsFetch =
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
? DEFAULT_BACKGROUND_SLOTS_FETCH
: prerenderOptions.backgroundSlotsFetch;
this.prerenderOptions = {
...prerenderOptions,
backgroundSlotsFetch,
};
// Add prerender query param
config.prerender = "true";
// If we are prerendering a headless router path, we don't want to record the response immediately.
if (isHeadlessRouterPath) {
config["cal.queueFormResponse"] = "true";
}
if (!backgroundSlotsFetch) {
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
config["cal.skipSlotsFetch"] = "true";
}
}
let enrichedConfig;
const configWithGuestKeyAndColorScheme = withColorScheme(
Cal.ensureGuestKey({
...config,
@@ -1037,41 +1041,99 @@ class CalApi {
containerEl
);
const previousEmbedRenderStartTime = this.cal.embedRenderStartTime;
const previousEmbedConfig = this.cal.embedConfig;
let enrichedPrerenderOptions;
if (isPrerendering) {
const preparationResult = this.cal.prepareForPrerender({
calLink,
calOrigin,
previousEmbedRenderStartTime,
prerenderOptions,
isHeadlessRouterPath,
});
if (!preparationResult) {
log(`Prevented unnecessary repeat prerender for ${calLink}`);
return {
status: "prerender-prevented",
};
}
enrichedPrerenderOptions = preparationResult.enrichedPrerenderOptions;
enrichedConfig = buildConfigWithPrerenderRelatedFields({
config: configWithGuestKeyAndColorScheme,
isHeadlessRouterPath,
backgroundSlotsFetch: !!this.prerenderOptions?.backgroundSlotsFetch,
});
} else {
this.cal.isPrerendering = false;
enrichedConfig = configWithGuestKeyAndColorScheme;
}
// `this.modalUid` is set in non-preload case(Temporarily not being-set)
// `this.prerenderedModalUid` is set for a modal created through "prerender"
const reusableModalUid = this.modalUid || this.prerenderedModalUid;
const uid = reusableModalUid || String(Date.now());
// Means whether there is already an attempt to use the prerendered modal
const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid);
const embedRenderStartTime = Date.now();
this.cal.embedRenderStartTime = embedRenderStartTime;
this.cal.embedConfig = configWithGuestKeyAndColorScheme;
const stateData = {
embedConfig: enrichedConfig,
previousEmbedConfig,
embedRenderStartTime,
previousEmbedRenderStartTime,
isConnectionInitiated,
// If prerenderOptions are available, use them. Otherwise use the prerenderOptions from the previous call to modal for prerender
prerenderOptions: enrichedPrerenderOptions ?? this.prerenderOptions ?? null,
};
this.cal.setModalRenderStartVariables({
embedConfig: enrichedConfig,
embedRenderStartTime,
});
return this.__modal({
calLink,
enrichedConfig,
calOrigin,
isPrerendering,
stateData,
uid,
containerEl,
});
}
async __modal({
uid,
calLink,
enrichedConfig,
calOrigin,
isPrerendering,
stateData,
containerEl,
}: {
uid: string;
calLink: string;
enrichedConfig: PrefillAndIframeAttrsConfig;
calOrigin: string;
isPrerendering: boolean;
stateData: ModalStateData;
containerEl: HTMLElement;
}) {
const calLinkUrlObject = new URL(calLink, calOrigin);
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
// isConnectionPossible
if (!!existingModalEl && !!this.cal.iframe) {
log(`Trying to reuse modal ${uid}`);
const stateData = {
embedConfig: configWithGuestKeyAndColorScheme,
previousEmbedConfig,
embedRenderStartTime,
previousEmbedRenderStartTime,
isConnectionInitiated,
prerenderOptions: this.prerenderOptions ?? null,
};
const lastLoadedUrlObject = this.cal.getLastLoadedLinkInframe();
const lastLoadedPathIsRouter = lastLoadedUrlObject?.pathname?.includes("/router");
// TODO: This branch needs to be removed as we don't want to support this use case anymore.
if (isHeadlessRouterPath && !lastLoadedPathIsRouter) {
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
existingModalEl.setAttribute("state", "loading");
// submitThroughHeadlessRouterInModal would further decide whether full page reload is needed or a connect would suffice
// actionToTake might be "fullReload" and still connect could work in case of headless router because there might be just query params change(which can be handled by connect) of calLink(i.e. ?form=formid&newParam=newValue)
await this.cal.submitThroughHeadlessRouterInModal({
modal: { uid, element: existingModalEl, calOrigin },
calLinkUrlObject,
stateData,
});
throw new Error(
"`prerender` instruction should have been fired with headless router path as calLink(i.e. router?form={FORM_ID}&PARAMS=VALUES)"
);
} else {
const actionToTake = this.cal.getNextActionForModal({
modal: { uid },
@@ -1080,7 +1142,7 @@ class CalApi {
});
if (actionToTake === "noAction") {
if (__prerender) {
if (isPrerendering) {
// A prerender instruction can never show the modal
return;
}
@@ -1100,22 +1162,20 @@ class CalApi {
calLink,
calOrigin,
iframe: this.cal.iframe,
config: configWithGuestKeyAndColorScheme,
config: enrichedConfig,
});
} else if (actionToTake === "connect" || actionToTake === "connect-no-slots-fetch") {
this.cal.doInIframe({
method: "connect",
arg: {
config: {
...configWithGuestKeyAndColorScheme,
...(actionToTake === "connect-no-slots-fetch"
? {
"cal.embed.noSlotsFetchOnConnect": "true",
}
: {}),
},
params: fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries()),
const paramsToAdd = fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries());
this.cal.connect({
config: {
...enrichedConfig,
...(actionToTake === "connect-no-slots-fetch"
? {
"cal.embed.noSlotsFetchOnConnect": "true",
}
: {}),
},
params: paramsToAdd,
});
}
}
@@ -1127,23 +1187,19 @@ class CalApi {
log(`Creating new modal ${uid}`);
if (__prerender) {
if (isPrerendering) {
this.prerenderedModalUid = uid;
} else {
// Intentionally not setting it to avoid the behaviour of reusing the same modal. It was disabled earlier but now can be enabled but we will enable it later.
// this.modalUid = uid;
}
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
let iframe = null;
if (!iframe) {
iframe = this.cal.createIframe({
calLink,
config: configWithGuestKeyAndColorScheme,
config: enrichedConfig,
calOrigin,
});
}
@@ -1152,9 +1208,9 @@ class CalApi {
iframe.style.height = "100%";
iframe.style.width = "100%";
const template = document.createElement("template");
const pageType = getConfigProp(configWithGuestKeyAndColorScheme, "cal.embed.pageType");
const theme = getConfigProp(configWithGuestKeyAndColorScheme, "theme");
const layout = getConfigProp(configWithGuestKeyAndColorScheme, "layout");
const pageType = getConfigProp(enrichedConfig, "cal.embed.pageType");
const theme = getConfigProp(enrichedConfig, "theme");
const layout = getConfigProp(enrichedConfig, "layout");
template.innerHTML = `<cal-modal-box
${generateDataAttributes({
@@ -1169,11 +1225,14 @@ class CalApi {
// Set state through setAttribute so that onAttributeChangedCallback is triggered
this.cal.modalBox.setAttribute("state", "loading");
if (__prerender) {
if (isPrerendering) {
this.cal.modalBox.setAttribute("state", "prerendering");
}
this.handleClose();
containerEl.appendChild(template.content);
return {
status: "created",
};
}
private handleClose() {
+6
View File
@@ -102,3 +102,9 @@ export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Rec
export type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
export type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
export type ModalPrerenderOptions = {
slotsStaleTimeMs?: number;
iframeForceReloadThresholdMs?: number;
backgroundSlotsFetch?: boolean;
};
+29
View File
@@ -196,3 +196,32 @@ export function buildSearchParamsFromConfig(config: PrefillAndIframeAttrsConfig)
}
return searchParams;
}
export function buildConfigWithPrerenderRelatedFields({
config,
isHeadlessRouterPath,
backgroundSlotsFetch,
}: {
config: PrefillAndIframeAttrsConfig;
isHeadlessRouterPath: boolean;
backgroundSlotsFetch: boolean;
}) {
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
// If we are prerendering a headless router path, we don't want to record the response immediately.
if (isHeadlessRouterPath) {
config["cal.queueFormResponse"] = "true";
}
if (!backgroundSlotsFetch) {
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
config["cal.skipSlotsFetch"] = "true";
}
return {
...config,
prerender: "true",
};
}