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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
104 changes: 104 additions & 0 deletions packages/core/test/typings/object-freeze.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
3 changes: 2 additions & 1 deletion packages/core/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
30 changes: 30 additions & 0 deletions packages/core/typings/global.d.ts
Original file line number Diff line number Diff line change
@@ -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<T>(o: T): Readonly<T>;

// eslint-disable-next-line @typescript-eslint/ban-types -- Matching TypeScript's official Object.freeze signature from lib.es5.d.ts
freeze<T extends Function>(f: T): T;

freeze<T extends {[idx: string]: U | null | undefined | object}, U extends string | bigint | number | boolean | symbol>(o: T): Readonly<T>;

freeze<T>(o: T): Readonly<T>;
}
}

export {};

Loading