@posthog/react 1.10.3 → 1.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,7 +24,8 @@ describe('PostHogErrorBoundary component', () => {
24
24
 
25
25
  it('should call captureException with error message', () => {
26
26
  const { container } = renderWithError({ message: 'Test error', fallback: <div></div> })
27
- expect(posthog.captureException).toHaveBeenCalledWith(new Error('Test error'), undefined)
27
+ expect(posthog.captureException).toHaveBeenCalledWith(expect.any(Error), undefined)
28
+ expectCapturedReactError()
28
29
  expect(container.innerHTML).toBe('<div></div>')
29
30
  expect(console.error).toHaveBeenCalledTimes(1)
30
31
  expect((console.error as any).mock.calls[0][1].message).toEqual('Test error')
@@ -32,14 +33,16 @@ describe('PostHogErrorBoundary component', () => {
32
33
 
33
34
  it('should warn user when fallback is null', () => {
34
35
  const { container } = renderWithError({ fallback: null })
35
- expect(posthog.captureException).toHaveBeenCalledWith(new Error('Error'), undefined)
36
+ expect(posthog.captureException).toHaveBeenCalledWith(expect.any(Error), undefined)
37
+ expectCapturedReactError()
36
38
  expect(container.innerHTML).toBe('')
37
39
  expect(console.warn).toHaveBeenCalledWith(__POSTHOG_ERROR_MESSAGES.INVALID_FALLBACK)
38
40
  })
39
41
 
40
42
  it('should warn user when fallback is a string', () => {
41
43
  const { container } = renderWithError({ fallback: 'hello' })
42
- expect(posthog.captureException).toHaveBeenCalledWith(new Error('Error'), undefined)
44
+ expect(posthog.captureException).toHaveBeenCalledWith(expect.any(Error), undefined)
45
+ expectCapturedReactError()
43
46
  expect(container.innerHTML).toBe('')
44
47
  expect(console.warn).toHaveBeenCalledWith(__POSTHOG_ERROR_MESSAGES.INVALID_FALLBACK)
45
48
  })
@@ -47,19 +50,40 @@ describe('PostHogErrorBoundary component', () => {
47
50
  it('should add additional properties before sending event (as object)', () => {
48
51
  const props = { team_id: '1234' }
49
52
  renderWithError({ message: 'Kaboom', additionalProperties: props })
50
- expect(posthog.captureException).toHaveBeenCalledWith(new Error('Kaboom'), props)
53
+ expect(posthog.captureException).toHaveBeenCalledWith(expect.any(Error), props)
54
+ expectCapturedReactError()
51
55
  })
52
56
 
53
57
  it('should add additional properties before sending event (as function)', () => {
54
58
  const props = { team_id: '1234' }
55
59
  renderWithError({
56
60
  message: 'Kaboom',
57
- additionalProperties: (err: Error) => {
61
+ additionalProperties: (err: Error, errorInfo: React.ErrorInfo) => {
58
62
  expect(err.message).toBe('Kaboom')
63
+ expect(errorInfo.componentStack).toContain('PostHogErrorBoundary.test.tsx')
59
64
  return props
60
65
  },
61
66
  })
62
- expect(posthog.captureException).toHaveBeenCalledWith(new Error('Kaboom'), props)
67
+ expect(posthog.captureException).toHaveBeenCalledWith(expect.any(Error), props)
68
+ expectCapturedReactError()
69
+ })
70
+
71
+ it('should capture the component stack for primitive exceptions', () => {
72
+ render(
73
+ <PostHogErrorBoundary fallback={<div></div>}>
74
+ <ComponentWithUndefinedError />
75
+ </PostHogErrorBoundary>
76
+ )
77
+
78
+ expect(posthog.captureException).toHaveBeenCalledWith(expect.any(Error), undefined)
79
+ const capturedError = (posthog.captureException as jest.Mock).mock.calls[0][0]
80
+ expect(capturedError).toEqual(
81
+ expect.objectContaining({
82
+ message: 'Primitive value captured as exception: undefined',
83
+ name: 'React ErrorBoundary Error',
84
+ stack: expect.stringContaining('ComponentWithUndefinedError'),
85
+ })
86
+ )
63
87
  })
64
88
 
65
89
  it('should render children without errors', () => {
@@ -88,12 +112,39 @@ describe('captureException processing', () => {
88
112
  const captureCalls = (posthog.capture as jest.Mock).mock.calls
89
113
  expect(captureCalls.length).toBe(1)
90
114
  const exceptionList = captureCalls[0][1].$exception_list
91
- expect(exceptionList.length).toBe(1)
115
+ expect(exceptionList.length).toBe(2)
92
116
  const stacktrace = exceptionList[0].stacktrace
93
117
  expect(stacktrace.frames.length).toBeGreaterThan(20)
118
+ expect(exceptionList[1].type).toBe('React ErrorBoundary Error')
119
+ expectComponentStackFrames(exceptionList[1].stacktrace.frames, 'PostHogErrorBoundary')
120
+ })
121
+
122
+ it('should parse the component stack for primitive exceptions', () => {
123
+ render(
124
+ <PostHogErrorBoundary fallback={<div></div>}>
125
+ <ComponentWithUndefinedError />
126
+ </PostHogErrorBoundary>
127
+ )
128
+
129
+ const captureCalls = (posthog.capture as jest.Mock).mock.calls
130
+ const exceptionList = captureCalls[0][1].$exception_list
131
+ expect(exceptionList).toHaveLength(1)
132
+ expect(exceptionList[0].type).toBe('React ErrorBoundary Error')
133
+ expectComponentStackFrames(exceptionList[0].stacktrace.frames, 'ComponentWithUndefinedError')
94
134
  })
95
135
  })
96
136
 
137
+ function expectComponentStackFrames(frames: Array<{ function?: string }>, expectedFunction: string) {
138
+ expect(frames.length).toBeGreaterThan(0)
139
+ expect(frames.some((frame) => frame.function === expectedFunction)).toBe(true)
140
+ }
141
+
142
+ function expectCapturedReactError() {
143
+ const capturedError = (posthog.captureException as jest.Mock).mock.calls[0][0]
144
+ expect(capturedError.cause.name).toBe('React ErrorBoundary Error')
145
+ expect(capturedError.cause.stack).toContain('PostHogErrorBoundary.test.tsx')
146
+ }
147
+
97
148
  function mockFunction(object: any, funcName: string) {
98
149
  const originalFunc = object[funcName]
99
150
 
@@ -110,6 +161,10 @@ function ComponentWithError({ message }: { message: string }): React.ReactElemen
110
161
  throw new Error(message)
111
162
  }
112
163
 
164
+ function ComponentWithUndefinedError(): React.ReactElement {
165
+ throw undefined
166
+ }
167
+
113
168
  function RenderWithError({ message = 'Error', fallback, additionalProperties }: any) {
114
169
  return (
115
170
  <PostHogErrorBoundary fallback={fallback} additionalProperties={additionalProperties}>
@@ -0,0 +1,43 @@
1
+ import type { ErrorInfo } from 'react'
2
+ import { setupReactErrorHandler } from '../error-helpers'
3
+
4
+ describe('setupReactErrorHandler', () => {
5
+ it('captures the React component stack', () => {
6
+ const captureException = jest.fn()
7
+ const errorInfo: ErrorInfo = { componentStack: '\n in CrashingComponent' }
8
+ const handler = setupReactErrorHandler({ captureException } as any)
9
+
10
+ handler(undefined, errorInfo)
11
+
12
+ expect(captureException).toHaveBeenCalledWith(
13
+ expect.objectContaining({
14
+ message: 'Primitive value captured as exception: undefined',
15
+ name: 'React ErrorBoundary Error',
16
+ stack: errorInfo.componentStack,
17
+ })
18
+ )
19
+ })
20
+
21
+ it('appends the React component stack after existing error causes', () => {
22
+ const captureException = jest.fn()
23
+ const errorInfo: ErrorInfo = { componentStack: '\n in CrashingComponent' }
24
+ const handler = setupReactErrorHandler({ captureException } as any)
25
+ const error = new Error('outer error') as Error & { cause?: unknown }
26
+ const cause = new Error('inner error') as Error & { cause?: unknown }
27
+ const nestedCause = new Error('nested error') as Error & { cause?: unknown }
28
+ error.cause = cause
29
+ cause.cause = nestedCause
30
+
31
+ handler(error, errorInfo)
32
+
33
+ expect(captureException).toHaveBeenCalledWith(error)
34
+ expect(error.cause).toBe(cause)
35
+ expect(cause.cause).toBe(nestedCause)
36
+ expect(nestedCause.cause).toEqual(
37
+ expect.objectContaining({
38
+ name: 'React ErrorBoundary Error',
39
+ stack: errorInfo.componentStack,
40
+ })
41
+ )
42
+ })
43
+ })
@@ -1,13 +1,14 @@
1
1
  import type { ErrorInfo } from 'react'
2
2
  import { PostHog } from '../context'
3
3
  import type { CaptureResult } from 'posthog-js'
4
+ import { addReactComponentStack } from './react-component-stack'
4
5
 
5
6
  export const setupReactErrorHandler = (
6
7
  client: PostHog,
7
8
  callback?: (event: CaptureResult | undefined, error: any, errorInfo: ErrorInfo) => void
8
9
  ) => {
9
10
  return (error: any, errorInfo: ErrorInfo): void => {
10
- const event = client.captureException(error)
11
+ const event = client.captureException(addReactComponentStack(error, errorInfo.componentStack))
11
12
  if (callback) {
12
13
  callback(event, error, errorInfo)
13
14
  }
@@ -0,0 +1,53 @@
1
+ import { isUndefined } from '../utils/type-utils'
2
+
3
+ type ErrorWithCause = Error & { cause?: unknown }
4
+
5
+ const isError = (value: unknown): value is ErrorWithCause => {
6
+ const tag = Object.prototype.toString.call(value)
7
+ return (
8
+ value instanceof Error ||
9
+ tag === '[object Error]' ||
10
+ tag === '[object Exception]' ||
11
+ tag === '[object DOMException]' ||
12
+ tag === '[object DOMError]'
13
+ )
14
+ }
15
+
16
+ const setCause = (error: ErrorWithCause, cause: ErrorWithCause): void => {
17
+ const seenErrors = new WeakSet<ErrorWithCause>()
18
+ let currentError = error
19
+
20
+ while (!seenErrors.has(currentError)) {
21
+ seenErrors.add(currentError)
22
+
23
+ if (!isError(currentError.cause)) {
24
+ if (!isUndefined(currentError.cause)) {
25
+ cause.cause = currentError.cause
26
+ }
27
+ currentError.cause = cause
28
+ return
29
+ }
30
+
31
+ currentError = currentError.cause
32
+ }
33
+ }
34
+
35
+ // Model React's component stack as a linked error so existing exception parsing and rendering can handle it.
36
+ export const addReactComponentStack = (error: unknown, componentStack?: string | null): unknown => {
37
+ if (!componentStack) {
38
+ return error
39
+ }
40
+
41
+ const componentStackError = new Error(
42
+ isError(error) ? error.message : `Primitive value captured as exception: ${String(error)}`
43
+ )
44
+ componentStackError.name = `React ErrorBoundary ${isError(error) ? error.name : 'Error'}`
45
+ componentStackError.stack = componentStack
46
+
47
+ if (isError(error)) {
48
+ setCause(error, componentStackError)
49
+ return error
50
+ }
51
+
52
+ return componentStackError
53
+ }
@@ -66,7 +66,6 @@ describe('feature flag hooks', () => {
66
66
  } as unknown as PostHog['featureFlags'],
67
67
  } as unknown as PostHog
68
68
 
69
- // eslint-disable-next-line react/display-name
70
69
  renderProvider = ({ children }) => <PostHogProvider client={posthog}>{children}</PostHogProvider>
71
70
  })
72
71
 
@@ -125,7 +124,6 @@ describe('feature flag hooks', () => {
125
124
  } as unknown as PostHog['featureFlags'],
126
125
  } as unknown as PostHog
127
126
 
128
- // eslint-disable-next-line react/display-name
129
127
  const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
130
128
  <PostHogProvider client={client}>{children}</PostHogProvider>
131
129
  )
@@ -197,7 +195,6 @@ describe('feature flag hooks', () => {
197
195
  } as unknown as PostHog['featureFlags'],
198
196
  } as unknown as PostHog
199
197
 
200
- // eslint-disable-next-line react/display-name
201
198
  const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
202
199
  <PostHogProvider client={client}>{children}</PostHogProvider>
203
200
  )
@@ -227,7 +224,6 @@ describe('feature flag hooks', () => {
227
224
  } as unknown as PostHog['featureFlags'],
228
225
  } as unknown as PostHog
229
226
 
230
- // eslint-disable-next-line react/display-name
231
227
  const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
232
228
  <PostHogProvider client={client}>{children}</PostHogProvider>
233
229
  )
@@ -306,7 +302,6 @@ describe('feature flag hooks', () => {
306
302
  } as unknown as PostHog['featureFlags'],
307
303
  } as unknown as PostHog
308
304
 
309
- // eslint-disable-next-line react/display-name
310
305
  const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
311
306
  <PostHogProvider client={client}>{children}</PostHogProvider>
312
307
  )
@@ -349,7 +344,6 @@ describe('feature flag hooks', () => {
349
344
  } as unknown as PostHog['featureFlags'],
350
345
  } as unknown as PostHog
351
346
 
352
- // eslint-disable-next-line react/display-name
353
347
  const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
354
348
  <PostHogProvider client={client}>{children}</PostHogProvider>
355
349
  )
@@ -11,6 +11,5 @@ export const isUndefined = function (x: unknown): x is undefined {
11
11
  }
12
12
 
13
13
  export const isNull = function (x: unknown): x is null {
14
- // eslint-disable-next-line posthog-js/no-direct-null-check
15
14
  return x === null
16
15
  }