Skip to content

Commit a863914

Browse files
authored
fix(core): fix captureException ReferenceError in React Native for missing Event global (#3296)
1 parent bec9814 commit a863914

File tree

6 files changed

+246
-1
lines changed

6 files changed

+246
-1
lines changed

.changeset/gentle-rivers-shine.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-react-native': patch
3+
---
4+
5+
Fix `captureException` crashing with `ReferenceError: Property 'Event' doesn't exist`

.changeset/warm-foxes-glow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@posthog/core': patch
3+
---
4+
5+
Fix `captureException` crashing in React Native with `ReferenceError: Property 'Event' doesn't exist`

.eslintrc.cjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const rules = {
1212
'no-console': 'error',
1313
'no-only-tests/no-only-tests': 'error',
1414
'posthog-js/no-external-replay-imports': 'error',
15+
'posthog-js/no-unsafe-web-global': 'off',
1516
'@typescript-eslint/naming-convention': [
1617
'error',
1718
{
@@ -94,6 +95,18 @@ module.exports = {
9495
'compat/compat': 'off',
9596
},
9697
},
98+
{
99+
// @posthog/core is shared by browser and React Native — Web API globals
100+
// like Event, ErrorEvent, etc. don't exist in Hermes/JSC and referencing
101+
// them as values throws a ReferenceError at runtime.
102+
files: ['packages/core/src/**'],
103+
excludedFiles: ['**/*.spec.*', '**/*.test.*'],
104+
rules: {
105+
'posthog-js/no-unsafe-web-global': 'error',
106+
},
107+
},
97108
],
98109
ignorePatterns: ['node_modules', 'dist', 'next-env.d.ts', '.next', 'packages/browser/playground/hydration/vendor'],
110+
111+
99112
}

packages/core/src/utils/type-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export function isErrorEvent(event: unknown): boolean {
119119
}
120120

121121
export function isEvent(candidate: unknown): candidate is Event {
122-
return !isUndefined(Event) && isInstanceOf(candidate, Event)
122+
return typeof Event !== 'undefined' && isInstanceOf(candidate, Event)
123123
}
124124

125125
export function isPlainObject(candidate: unknown): candidate is Record<string, unknown> {

tooling/eslint-plugin-posthog-js/custom-eslint-rules.test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const noDirectDateCheck = require('./no-direct-date-check')
88
const noDirectNumberCheck = require('./no-direct-number-check')
99
const noDirectBooleanCheck = require('./no-direct-boolean-check')
1010
const noAddEventListener = require('./no-add-event-listener')
11+
const noUnsafeWebGlobal = require('./no-unsafe-web-global')
1112

1213
const { RuleTester } = require('eslint')
1314

@@ -132,6 +133,61 @@ ruleTester.run('no-direct-boolean-check', noDirectBooleanCheck, {
132133
],
133134
})
134135

136+
const tsRuleTester = new RuleTester({
137+
parser: require.resolve('@typescript-eslint/parser'),
138+
parserOptions: {
139+
ecmaVersion: 2015,
140+
sourceType: 'module',
141+
},
142+
})
143+
144+
tsRuleTester.run('no-unsafe-web-global', noUnsafeWebGlobal, {
145+
valid: [
146+
// typeof guard is safe
147+
{ code: `typeof Event !== 'undefined'` },
148+
// guarded by typeof via short-circuit &&
149+
{ code: `typeof Event !== 'undefined' && isInstanceOf(candidate, Event)` },
150+
// type annotation
151+
{ code: `function foo(e: Event) {}` },
152+
// type predicate
153+
{ code: `function foo(e: unknown): e is Event { return true }` },
154+
// property access
155+
{ code: `obj.Event` },
156+
// non-web-global identifier
157+
{ code: `const x = SomeOtherThing` },
158+
],
159+
invalid: [
160+
// direct value reference
161+
{
162+
code: `const x = Event`,
163+
errors: [{ messageId: 'unsafeWebGlobal' }],
164+
},
165+
// new expression
166+
{
167+
code: `new Event('test')`,
168+
errors: [{ messageId: 'unsafeWebGlobal' }],
169+
},
170+
// instanceof without typeof guard
171+
{
172+
code: `candidate instanceof Event`,
173+
errors: [{ messageId: 'unsafeWebGlobal' }],
174+
},
175+
// isUndefined(Event) — the original bug pattern
176+
{
177+
code: `!isUndefined(Event) && isInstanceOf(candidate, Event)`,
178+
errors: [
179+
{ messageId: 'unsafeWebGlobal' },
180+
{ messageId: 'unsafeWebGlobal' },
181+
],
182+
},
183+
// other web globals
184+
{
185+
code: `new MutationObserver(() => {})`,
186+
errors: [{ messageId: 'unsafeWebGlobal' }],
187+
},
188+
],
189+
})
190+
135191
ruleTester.run('no-add-event-listener', noAddEventListener, {
136192
valid: [
137193
{ code: "addEventListener(document, 'click', () => {}, { passive: true })" },
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* Flags direct value-level references to Web API globals that don't exist in React Native.
3+
*
4+
* In environments like Hermes/JSC, referencing `Event` (or similar) as a value throws
5+
* `ReferenceError: Property 'Event' doesn't exist`. Use `typeof Event !== 'undefined'`
6+
* for existence checks, or `isBuiltin(val, 'Event')` for type checks.
7+
*
8+
* Type-level references (annotations, generics, type guards) are allowed.
9+
*/
10+
11+
const WEB_GLOBALS = new Set([
12+
'Event',
13+
'ErrorEvent',
14+
'PromiseRejectionEvent',
15+
'CustomEvent',
16+
'MouseEvent',
17+
'KeyboardEvent',
18+
'TouchEvent',
19+
'PointerEvent',
20+
'FocusEvent',
21+
'InputEvent',
22+
'ClipboardEvent',
23+
'DragEvent',
24+
'AnimationEvent',
25+
'TransitionEvent',
26+
'PopStateEvent',
27+
'HashChangeEvent',
28+
'PageTransitionEvent',
29+
'MutationObserver',
30+
'IntersectionObserver',
31+
'ResizeObserver',
32+
'PerformanceObserver',
33+
'XMLHttpRequest',
34+
'WebSocket',
35+
'Worker',
36+
'SharedWorker',
37+
'BroadcastChannel',
38+
'MessageChannel',
39+
'MessagePort',
40+
])
41+
42+
module.exports = {
43+
meta: {
44+
type: 'problem',
45+
docs: {
46+
description:
47+
'Disallow direct value-level references to Web API globals that may not exist in React Native',
48+
},
49+
messages: {
50+
unsafeWebGlobal:
51+
"Direct value reference to '{{name}}' will throw a ReferenceError in React Native. " +
52+
"Use `typeof {{name}} !== 'undefined'` for existence checks, or `isBuiltin(val, '{{name}}')` for type checks.",
53+
},
54+
},
55+
create(context) {
56+
/**
57+
* Check if a node is guarded by a `typeof X !== 'undefined'` check
58+
* on the left side of a short-circuit `&&` expression.
59+
* e.g. `typeof Event !== 'undefined' && isInstanceOf(candidate, Event)`
60+
*/
61+
function isGuardedByTypeofCheck(node, globalName) {
62+
let current = node
63+
while (current.parent) {
64+
const parent = current.parent
65+
if (parent.type === 'LogicalExpression' && parent.operator === '&&' && parent.right === current) {
66+
if (containsTypeofGuard(parent.left, globalName)) {
67+
return true
68+
}
69+
}
70+
current = parent
71+
}
72+
return false
73+
}
74+
75+
function containsTypeofGuard(node, globalName) {
76+
if (node.type === 'BinaryExpression' && (node.operator === '!==' || node.operator === '!=')) {
77+
return hasTypeofOperand(node, globalName) && hasUndefinedOperand(node)
78+
}
79+
if (node.type === 'LogicalExpression' && node.operator === '&&') {
80+
return containsTypeofGuard(node.left, globalName) || containsTypeofGuard(node.right, globalName)
81+
}
82+
return false
83+
}
84+
85+
function hasTypeofOperand(binaryNode, globalName) {
86+
return (
87+
(binaryNode.left.type === 'UnaryExpression' &&
88+
binaryNode.left.operator === 'typeof' &&
89+
binaryNode.left.argument.type === 'Identifier' &&
90+
binaryNode.left.argument.name === globalName) ||
91+
(binaryNode.right.type === 'UnaryExpression' &&
92+
binaryNode.right.operator === 'typeof' &&
93+
binaryNode.right.argument.type === 'Identifier' &&
94+
binaryNode.right.argument.name === globalName)
95+
)
96+
}
97+
98+
function hasUndefinedOperand(binaryNode) {
99+
return (
100+
(binaryNode.left.type === 'Literal' && binaryNode.left.value === 'undefined') ||
101+
(binaryNode.right.type === 'Literal' && binaryNode.right.value === 'undefined')
102+
)
103+
}
104+
105+
return {
106+
Identifier(node) {
107+
if (!WEB_GLOBALS.has(node.name)) {
108+
return
109+
}
110+
111+
const parent = node.parent
112+
113+
// Allow: typeof Event (safe existence check)
114+
if (parent.type === 'UnaryExpression' && parent.operator === 'typeof') {
115+
return
116+
}
117+
118+
// Allow: value reference guarded by typeof check via short-circuit &&
119+
// e.g. `typeof Event !== 'undefined' && isInstanceOf(candidate, Event)`
120+
if (isGuardedByTypeofCheck(node, node.name)) {
121+
return
122+
}
123+
124+
// Allow: all TypeScript type-level usage (annotations, generics, predicates, implements/extends)
125+
if (
126+
parent.type === 'TSTypeReference' ||
127+
parent.type === 'TSTypeAnnotation' ||
128+
parent.type === 'TSTypeQuery' ||
129+
parent.type === 'TSQualifiedName' ||
130+
parent.type === 'TSTypeParameterInstantiation' ||
131+
parent.type === 'TSTypePredicate' ||
132+
parent.type === 'TSClassImplements' ||
133+
parent.type === 'TSInterfaceHeritage'
134+
) {
135+
return
136+
}
137+
138+
// Allow: property access on an object (e.g. err.Event, obj.CustomEvent)
139+
if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
140+
return
141+
}
142+
143+
// Allow: string literals that happen to match (e.g. 'Event' in isBuiltin calls)
144+
if (node.type === 'Literal') {
145+
return
146+
}
147+
148+
149+
// Allow: import specifiers
150+
if (
151+
parent.type === 'ImportSpecifier' ||
152+
parent.type === 'ImportDefaultSpecifier' ||
153+
parent.type === 'ImportNamespaceSpecifier'
154+
) {
155+
return
156+
}
157+
158+
context.report({
159+
node,
160+
messageId: 'unsafeWebGlobal',
161+
data: { name: node.name },
162+
})
163+
},
164+
}
165+
},
166+
}

0 commit comments

Comments
 (0)