diff --git a/.changeset/onmessage-callback-error.md b/.changeset/onmessage-callback-error.md new file mode 100644 index 00000000..220d6049 --- /dev/null +++ b/.changeset/onmessage-callback-error.md @@ -0,0 +1,5 @@ +--- +'@callstack/react-native-brownfield': patch +--- + +`ReactNativeBrownfield.onMessage` no longer calls the callback a second time, with the raw message text, when the callback throws. The error now propagates instead of being swallowed. diff --git a/packages/react-native-brownfield/src/__tests__/onMessage.test.ts b/packages/react-native-brownfield/src/__tests__/onMessage.test.ts new file mode 100644 index 00000000..53a85007 --- /dev/null +++ b/packages/react-native-brownfield/src/__tests__/onMessage.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const native = vi.hoisted(() => ({ + listener: undefined as ((payload: { text: string }) => void) | undefined, + remove: vi.fn(), +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + NativeModules: {}, +})); +vi.mock('../NativeReactNativeBrownfieldModule', () => ({ + default: { + onBrownfieldMessage: (listener: (payload: { text: string }) => void) => { + native.listener = listener; + return { remove: native.remove }; + }, + }, +})); + +import ReactNativeBrownfield from '../index'; + +function emit(text: string) { + native.listener?.({ text }); +} + +describe('onMessage', () => { + beforeEach(() => { + native.listener = undefined; + native.remove.mockClear(); + }); + + it('passes parsed JSON to the callback', () => { + const callback = vi.fn(); + ReactNativeBrownfield.onMessage(callback); + + emit('{"text":"hello","id":1}'); + + expect(callback).toHaveBeenCalledExactlyOnceWith({ + data: { text: 'hello', id: 1 }, + }); + }); + + it('passes the raw text when the message is not valid JSON', () => { + const callback = vi.fn(); + ReactNativeBrownfield.onMessage(callback); + + emit('not json'); + + expect(callback).toHaveBeenCalledExactlyOnceWith({ data: 'not json' }); + }); + + it('does not call the callback again when it throws', () => { + const error = new Error('callback failed'); + const callback = vi.fn(() => { + throw error; + }); + ReactNativeBrownfield.onMessage(callback); + + expect(() => emit('{"text":"hello"}')).toThrow(error); + expect(callback).toHaveBeenCalledExactlyOnceWith({ + data: { text: 'hello' }, + }); + }); + + it('removes the native subscription', () => { + const subscription = ReactNativeBrownfield.onMessage(vi.fn()); + + subscription.remove(); + + expect(native.remove).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/react-native-brownfield/src/index.ts b/packages/react-native-brownfield/src/index.ts index 31eaa975..5c9f3277 100644 --- a/packages/react-native-brownfield/src/index.ts +++ b/packages/react-native-brownfield/src/index.ts @@ -96,11 +96,13 @@ const ReactNativeBrownfield = { ): { remove: () => void } => { const subscription = ReactNativeBrownfieldModule.onBrownfieldMessage( (payload) => { + let data: unknown; try { - callback({ data: JSON.parse(payload.text) }); + data = JSON.parse(payload.text); } catch { - callback({ data: payload.text }); + data = payload.text; } + callback({ data }); } ); return { remove: () => subscription.remove() };