diff --git a/CHANGELOG.md b/CHANGELOG.md index e88e3d7e46..bb3d242ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Fixes + +- Fix `Object.freeze` type pollution from `@sentry-internal/replay` ([#5408](https://github.com/getsentry/sentry-react-native/issues/5408)) + ### Dependencies - Bump Android SDK from v8.27.0 to v8.27.1 ([#5404](https://github.com/getsentry/sentry-react-native/pull/5404)) diff --git a/packages/core/test/typings/object-freeze.test.ts b/packages/core/test/typings/object-freeze.test.ts new file mode 100644 index 0000000000..1135b4489e --- /dev/null +++ b/packages/core/test/typings/object-freeze.test.ts @@ -0,0 +1,104 @@ +/** + * Type test for Object.freeze pollution fix + * + * This test ensures that Object.freeze() resolves to the correct built-in type + * and not to a polluted type from @sentry-internal/replay. + * + * See: https://github.com/getsentry/sentry-react-native/issues/5407 + */ + +describe('Object.freeze type inference', () => { + it('should correctly freeze plain objects', () => { + const frozenObject = Object.freeze({ + key: 'value', + num: 42, + }); + + // Runtime test: should be frozen + expect(Object.isFrozen(frozenObject)).toBe(true); + + // Type test: TypeScript should infer Readonly<{ key: string; num: number; }> + expect(frozenObject.key).toBe('value'); + expect(frozenObject.num).toBe(42); + }); + + it('should correctly freeze objects with as const', () => { + const EVENTS = Object.freeze({ + CLICK: 'click', + SUBMIT: 'submit', + } as const); + + // Runtime test: should be frozen + expect(Object.isFrozen(EVENTS)).toBe(true); + + // Type test: TypeScript should infer literal types + expect(EVENTS.CLICK).toBe('click'); + expect(EVENTS.SUBMIT).toBe('submit'); + + // TypeScript should infer: Readonly<{ CLICK: "click"; SUBMIT: "submit"; }> + const eventType: 'click' | 'submit' = EVENTS.CLICK; + expect(eventType).toBe('click'); + }); + + it('should correctly freeze functions', () => { + const frozenFn = Object.freeze((x: number) => x * 2); + + // Runtime test: should be frozen + expect(Object.isFrozen(frozenFn)).toBe(true); + + // Type test: function should still be callable + const result: number = frozenFn(5); + expect(result).toBe(10); + }); + + it('should correctly freeze nested objects', () => { + const ACTIONS = Object.freeze({ + USER: Object.freeze({ + LOGIN: 'user:login', + LOGOUT: 'user:logout', + } as const), + ADMIN: Object.freeze({ + DELETE: 'admin:delete', + } as const), + } as const); + + // Runtime test: should be frozen + expect(Object.isFrozen(ACTIONS)).toBe(true); + expect(Object.isFrozen(ACTIONS.USER)).toBe(true); + expect(Object.isFrozen(ACTIONS.ADMIN)).toBe(true); + + // Type test: should preserve nested literal types + expect(ACTIONS.USER.LOGIN).toBe('user:login'); + expect(ACTIONS.ADMIN.DELETE).toBe('admin:delete'); + + // TypeScript should infer the correct literal type + const action: 'user:login' = ACTIONS.USER.LOGIN; + expect(action).toBe('user:login'); + }); + + it('should maintain type safety and prevent modifications at compile time', () => { + const frozen = Object.freeze({ value: 42 }); + + // Runtime: attempting to modify should silently fail (in non-strict mode) + // or throw (in strict mode) + expect(() => { + // @ts-expect-error - TypeScript should prevent this at compile time + (frozen as any).value = 100; + }).not.toThrow(); // In non-strict mode, this silently fails + + // Value should remain unchanged + expect(frozen.value).toBe(42); + }); + + it('should work with array freeze', () => { + const frozenArray = Object.freeze([1, 2, 3]); + + // Runtime test + expect(Object.isFrozen(frozenArray)).toBe(true); + expect(frozenArray).toEqual([1, 2, 3]); + + // Array methods that don't mutate should still work + expect(frozenArray.map(x => x * 2)).toEqual([2, 4, 6]); + expect(frozenArray.filter(x => x > 1)).toEqual([2, 3]); + }); +}); diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json index ac956235ba..47380c4685 100644 --- a/packages/core/tsconfig.build.json +++ b/packages/core/tsconfig.build.json @@ -7,7 +7,8 @@ "./src/js/playground/*.tsx", "./src/js/**/*.web.ts", "./src/js/**/*.web.tsx", - "./typings/react-native.d.ts" + "./typings/react-native.d.ts", + "./typings/global.d.ts" ], "exclude": ["node_modules"], "compilerOptions": { diff --git a/packages/core/typings/global.d.ts b/packages/core/typings/global.d.ts new file mode 100644 index 0000000000..fa1125a77d --- /dev/null +++ b/packages/core/typings/global.d.ts @@ -0,0 +1,30 @@ +/** + * Global type augmentations for the Sentry React Native SDK + * + * This file contains global type fixes and augmentations to resolve conflicts + * with transitive dependencies. + */ + +/** + * Fix for Object.freeze type pollution from @sentry-internal/replay + * + * Issue: TypeScript incorrectly resolves Object.freeze() to a freeze method + * from @sentry-internal/replay's CanvasManagerInterface instead of the built-in. + * + * See: https://github.com/getsentry/sentry-react-native/issues/5407 + */ +declare global { + interface ObjectConstructor { + freeze(o: T): Readonly; + + // eslint-disable-next-line @typescript-eslint/ban-types -- Matching TypeScript's official Object.freeze signature from lib.es5.d.ts + freeze(f: T): T; + + freeze(o: T): Readonly; + + freeze(o: T): Readonly; + } +} + +export {}; +