@posthog/react 1.10.2 → 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
 
@@ -103,6 +102,36 @@ describe('feature flag hooks', () => {
103
102
  expect(result.current).toEqual(['example_feature_true', 'multivariate_feature', 'example_feature_payload'])
104
103
  })
105
104
 
105
+ it.each([
106
+ ['enabled_flag', true],
107
+ ['disabled_flag', false],
108
+ ['multivariate_flag', true],
109
+ ])('should report bootstrap feature flag %s active status as %s', (flag, expected) => {
110
+ const client = {
111
+ onFeatureFlags: () => () => {},
112
+ config: {
113
+ bootstrap: {
114
+ featureFlags: {
115
+ enabled_flag: true,
116
+ disabled_flag: false,
117
+ multivariate_flag: 'variant-a',
118
+ },
119
+ },
120
+ },
121
+ featureFlags: {
122
+ getFlags: () => [],
123
+ hasLoadedFlags: false,
124
+ } as unknown as PostHog['featureFlags'],
125
+ } as unknown as PostHog
126
+
127
+ const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
128
+ <PostHogProvider client={client}>{children}</PostHogProvider>
129
+ )
130
+
131
+ const { result } = renderHook(() => useActiveFeatureFlags(), { wrapper })
132
+ expect(result.current.includes(flag)).toBe(expected)
133
+ })
134
+
106
135
  it.each([
107
136
  ['example_feature_true', true],
108
137
  ['example_feature_false', false],
@@ -166,7 +195,6 @@ describe('feature flag hooks', () => {
166
195
  } as unknown as PostHog['featureFlags'],
167
196
  } as unknown as PostHog
168
197
 
169
- // eslint-disable-next-line react/display-name
170
198
  const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
171
199
  <PostHogProvider client={client}>{children}</PostHogProvider>
172
200
  )
@@ -196,7 +224,6 @@ describe('feature flag hooks', () => {
196
224
  } as unknown as PostHog['featureFlags'],
197
225
  } as unknown as PostHog
198
226
 
199
- // eslint-disable-next-line react/display-name
200
227
  const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
201
228
  <PostHogProvider client={client}>{children}</PostHogProvider>
202
229
  )
@@ -275,7 +302,6 @@ describe('feature flag hooks', () => {
275
302
  } as unknown as PostHog['featureFlags'],
276
303
  } as unknown as PostHog
277
304
 
278
- // eslint-disable-next-line react/display-name
279
305
  const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
280
306
  <PostHogProvider client={client}>{children}</PostHogProvider>
281
307
  )
@@ -318,7 +344,6 @@ describe('feature flag hooks', () => {
318
344
  } as unknown as PostHog['featureFlags'],
319
345
  } as unknown as PostHog
320
346
 
321
- // eslint-disable-next-line react/display-name
322
347
  const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
323
348
  <PostHogProvider client={client}>{children}</PostHogProvider>
324
349
  )
@@ -14,7 +14,9 @@ export function useActiveFeatureFlags(): string[] {
14
14
 
15
15
  // if the client is not loaded yet and we have a bootstrapped value, use it
16
16
  if (!client?.featureFlags?.hasLoadedFlags && bootstrap?.featureFlags) {
17
- return Object.keys(bootstrap.featureFlags)
17
+ return Object.entries(bootstrap.featureFlags)
18
+ .filter(([, value]) => value)
19
+ .map(([key]) => key)
18
20
  }
19
21
 
20
22
  return featureFlags
@@ -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
  }