From ad4fd259c8f62b1fc8bd18aecac6e568c430b97e Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 26 Nov 2025 12:02:53 +0900 Subject: [PATCH 1/2] fix(partysocket): add React Native environment detection for dispatchEvent React Native/Expo environments have both `process` and `document` polyfilled, but not `process.versions.node`. This caused the library to incorrectly use browser-style event cloning, which produces events that fail `instanceof Event` checks in event-target-polyfill. This fix adds explicit React Native detection using the standard `navigator.product === "ReactNative"` check, and uses Node-style event cloning which creates proper Event instances. Fixes #257 --- .../src/tests/react-native.test.ts | 101 ++++++++++++++++++ packages/partysocket/src/ws.ts | 9 +- 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/partysocket/src/tests/react-native.test.ts diff --git a/packages/partysocket/src/tests/react-native.test.ts b/packages/partysocket/src/tests/react-native.test.ts new file mode 100644 index 0000000..c043ea7 --- /dev/null +++ b/packages/partysocket/src/tests/react-native.test.ts @@ -0,0 +1,101 @@ +/** + * @vitest-environment jsdom + * + * Tests for React Native environment detection and event cloning + * See: https://github.com/cloudflare/partykit/issues/257 + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +describe("React Native environment detection", () => { + const originalNavigator = globalThis.navigator; + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + // Restore original navigator + Object.defineProperty(globalThis, "navigator", { + value: originalNavigator, + writable: true, + configurable: true + }); + }); + + test("detects React Native environment via navigator.product", async () => { + // Mock React Native environment + Object.defineProperty(globalThis, "navigator", { + value: { product: "ReactNative" }, + writable: true, + configurable: true + }); + + // Re-import the module to pick up the new navigator value + const { default: ReconnectingWebSocket } = await import("../ws"); + + // The module should have been loaded with isReactNative = true + // We verify this by checking that the class can be instantiated + expect(ReconnectingWebSocket).toBeDefined(); + }); + + test("cloneEventNode creates valid Event instances", async () => { + // Import the module in a standard environment first + const wsModule = await import("../ws"); + + // Test that CloseEvent and ErrorEvent are proper Event subclasses + const closeEvent = new wsModule.CloseEvent(1000, "test", {}); + expect(closeEvent).toBeInstanceOf(Event); + expect(closeEvent.type).toBe("close"); + expect(closeEvent.code).toBe(1000); + expect(closeEvent.reason).toBe("test"); + + const errorEvent = new wsModule.ErrorEvent(new Error("test error"), {}); + expect(errorEvent).toBeInstanceOf(Event); + expect(errorEvent.type).toBe("error"); + expect(errorEvent.message).toBe("test error"); + }); + + test("event classes can be dispatched via EventTarget", async () => { + const wsModule = await import("../ws"); + + const target = new EventTarget(); + let receivedEvent: Event | null = null; + + target.addEventListener("close", (e) => { + receivedEvent = e; + }); + + const closeEvent = new wsModule.CloseEvent(1000, "normal closure", {}); + target.dispatchEvent(closeEvent); + + expect(receivedEvent).not.toBeNull(); + expect(receivedEvent).toBeInstanceOf(Event); + }); +}); + +describe("Event cloning for dispatchEvent", () => { + test("cloned MessageEvent maintains data property", () => { + const originalEvent = new MessageEvent("message", { data: "test data" }); + const clonedEvent = new MessageEvent(originalEvent.type, originalEvent); + + expect(clonedEvent).toBeInstanceOf(Event); + expect(clonedEvent).toBeInstanceOf(MessageEvent); + expect(clonedEvent.data).toBe("test data"); + }); + + test("cloned Event can be dispatched", () => { + const target = new EventTarget(); + let eventReceived = false; + + target.addEventListener("open", () => { + eventReceived = true; + }); + + const originalEvent = new Event("open"); + const clonedEvent = new Event(originalEvent.type, originalEvent); + + target.dispatchEvent(clonedEvent); + expect(eventReceived).toBe(true); + }); +}); diff --git a/packages/partysocket/src/ws.ts b/packages/partysocket/src/ws.ts index 063f4de..69d316d 100644 --- a/packages/partysocket/src/ws.ts +++ b/packages/partysocket/src/ws.ts @@ -98,7 +98,14 @@ const isNode = typeof process.versions?.node !== "undefined" && typeof document === "undefined"; -const cloneEvent = isNode ? cloneEventNode : cloneEventBrowser; +// React Native has process and document polyfilled but not process.versions.node +// It needs Node-style event cloning because browser-style cloning produces +// events that fail instanceof Event checks in event-target-polyfill +// See: https://github.com/cloudflare/partykit/issues/257 +const isReactNative = + typeof navigator !== "undefined" && navigator.product === "ReactNative"; + +const cloneEvent = isNode || isReactNative ? cloneEventNode : cloneEventBrowser; export type Options = { // biome-ignore lint/suspicious/noExplicitAny: legacy From f5851a9d564256f7bbab0730ec01122536fa9bc1 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 26 Nov 2025 12:11:09 +0900 Subject: [PATCH 2/2] chore: add changeset for React Native fix --- .changeset/fix-react-native-dispatchevent.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/fix-react-native-dispatchevent.md diff --git a/.changeset/fix-react-native-dispatchevent.md b/.changeset/fix-react-native-dispatchevent.md new file mode 100644 index 0000000..d671907 --- /dev/null +++ b/.changeset/fix-react-native-dispatchevent.md @@ -0,0 +1,9 @@ +--- +"partysocket": patch +--- + +Fix React Native/Expo `dispatchEvent` TypeError + +Added React Native environment detection to use Node-style event cloning. React Native/Expo environments have both `process` and `document` polyfilled but not `process.versions.node`, which caused browser-style event cloning to be selected incorrectly. Browser-style cloning produces events that fail `instanceof Event` checks in `event-target-polyfill`. + +Fixes #257