diff --git a/.changeset/tidy-pandas-shake.md b/.changeset/tidy-pandas-shake.md new file mode 100644 index 00000000..48d20a87 --- /dev/null +++ b/.changeset/tidy-pandas-shake.md @@ -0,0 +1,5 @@ +--- +"@feltmaps/js-sdk": patch +--- + +Fix `Felt.connect` intermittently failing in Safari with `DataCloneError` by creating a fresh `MessageChannel` for each handshake attempt instead of re-transferring the same port diff --git a/src/modules/main/index.ts b/src/modules/main/index.ts index 0c645e73..76229291 100644 --- a/src/modules/main/index.ts +++ b/src/modules/main/index.ts @@ -173,29 +173,48 @@ export const Felt = { const failureTimeout = setTimeout(() => { reject(new Error("Failed to load Felt map")); clearInterval(interval); + closeChannels(); }, 5_000); - const messageChannel = new MessageChannel(); - const interval = setInterval(() => { + // Transferring a MessagePort permanently neuters it, so every handshake + // attempt needs its own MessageChannel. Re-transferring the same port on + // each retry throws a DataCloneError in Safari (Chrome and Firefox + // silently drop the transfer), meaning that if the map wasn't ready to + // receive the very first message, no retry could ever deliver a + // working port. + const channels: MessageChannel[] = []; + + function closeChannels() { + for (const channel of channels) { + channel.port1.onmessage = null; + channel.port1.close(); + channel.port2.close(); + } + channels.length = 0; + } + + function attemptHandshake() { + const messageChannel = new MessageChannel(); + channels.push(messageChannel); + + messageChannel.port1.onmessage = (event) => { + if (event.data === true) { + clearInterval(interval); + clearTimeout(failureTimeout); + closeChannels(); + resolve(controller); + } + }; + feltWindow.postMessage({ type: "felt.ready" }, "*", [ messageChannel.port2, ]); - }, 100); + } + + const interval = setInterval(attemptHandshake, 100); // try immediately to see if the map is already ready - feltWindow.postMessage({ type: "felt.ready" }, "*", [ - messageChannel.port2, - ]); - - messageChannel.port1.onmessage = (event) => { - if (event.data === true) { - clearInterval(interval); - clearTimeout(failureTimeout); - messageChannel.port1.close(); - messageChannel.port2.close(); - resolve(controller); - } - }; + attemptHandshake(); }); }, }; diff --git a/tests/client.test.ts b/tests/client.test.ts index 1ebc84e9..af564099 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -108,6 +108,33 @@ describe("Embedding an iframe with theFelt SDK", () => { }); }); + describe("connect", () => { + test("sends a fresh MessagePort on each handshake attempt and connects even if early attempts are missed", async () => { + const receivedPorts: Array = []; + + // Simulate an iframe that isn't ready to receive the first handshake + // messages: ignore the first two attempts and only reply on the third. + window.addEventListener("message", (event) => { + if (event.data.type === "felt.ready") { + const port = event.ports[0]; + receivedPorts.push(port); + if (receivedPorts.length >= 3) { + port?.postMessage(true); + } + } + }); + + const controller = await Felt.connect(window); + expect(controller).toBeTruthy(); + + // Transferring a MessagePort neuters it, so re-transferring the same + // port on retries throws a DataCloneError in Safari. Each attempt must + // therefore transfer a freshly-minted port. + expect(receivedPorts.length).toBeGreaterThanOrEqual(3); + expect(new Set(receivedPorts).size).toBe(receivedPorts.length); + }); + }); + describe("controller types", () => { // These tests don't do anything in runtime, but they make sure that the types // are correct. Errors should be caught by the TypeScript compiler, ensuring