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:
1 parent
f8a1bd7b94
commit
9f6c05f389
6 files changed
+454
-727
No files matched your search
+52
-13
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user