Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-pandas-shake.md
Original file line number Diff line number Diff line change
@@ -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
51 changes: 35 additions & 16 deletions src/modules/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
},
};
Expand Down
27 changes: 27 additions & 0 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessagePort | undefined> = [];

// 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
Expand Down
Loading