Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/fix-react-native-dispatchevent.md
Original file line number Diff line number Diff line change
@@ -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
101 changes: 101 additions & 0 deletions packages/partysocket/src/tests/react-native.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 8 additions & 1 deletion packages/partysocket/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down