From 83d8b577e83bcd4370422cd4c2908b82e365f603 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 17:53:44 +0000 Subject: [PATCH] Fix Safari DataCloneError in Felt.connect by minting a fresh MessageChannel per handshake attempt Felt.connect created a single MessageChannel and re-transferred the same port2 on every 100ms handshake retry. Transferring a MessagePort permanently neuters it, so only the very first postMessage carried a usable port: Safari throws DataCloneError on every retry, while Chrome and Firefox silently drop the transfer. If the embedded map missed the first message (cold cache, slow network), the retry loop could never deliver a working port and connect() rejected after its 5s timeout. Now each attempt (the immediate one and every retry) creates its own MessageChannel, and the attempt that receives the ready reply resolves the connection. On success or timeout, the interval and timeout are cleared and all ports from stale attempts are closed. Adds a regression test that ignores the first two handshake attempts and asserts a distinct, freshly-minted port is transferred each time. --- .changeset/tidy-pandas-shake.md | 5 ++++ src/modules/main/index.ts | 51 ++++++++++++++++++++++----------- tests/client.test.ts | 27 +++++++++++++++++ 3 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 .changeset/tidy-pandas-shake.md 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