diff --git a/packages/embeds/README.md b/packages/embeds/README.md index 8c05df0520..e0e9f885f9 100644 --- a/packages/embeds/README.md +++ b/packages/embeds/README.md @@ -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 + +``` + +**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 + +``` \ No newline at end of file diff --git a/packages/embeds/embed-core/src/constants.ts b/packages/embeds/embed-core/src/constants.ts index ef3d089037..16a7a3b63f 100644 --- a/packages/embeds/embed-core/src/constants.ts +++ b/packages/embeds/embed-core/src/constants.ts @@ -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( diff --git a/packages/embeds/embed-core/src/embed.test.ts b/packages/embeds/embed-core/src/embed.test.ts index 2194883a66..80d43b29d2 100644 --- a/packages/embeds/embed-core/src/embed.test.ts +++ b/packages/embeds/embed-core/src/embed.test.ts @@ -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 } { - 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 } { - 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 } { - 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 } { - 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); })(); }); }); diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index 3251e25e0d..86b68bf1cd 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -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) { 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; + }) { + // 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 = `>; export type setNonStylesConfig = React.Dispatch>; + +export type ModalPrerenderOptions = { + slotsStaleTimeMs?: number; + iframeForceReloadThresholdMs?: number; + backgroundSlotsFetch?: boolean; +}; diff --git a/packages/embeds/embed-core/src/utils.ts b/packages/embeds/embed-core/src/utils.ts index 5955e51e79..b26c2ed42f 100644 --- a/packages/embeds/embed-core/src/utils.ts +++ b/packages/embeds/embed-core/src/utils.ts @@ -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", + }; +}