Skip to content

Commit cb93a66

Browse files
committed
feat: we can lazy load the autocapture
1 parent 557fbbc commit cb93a66

File tree

18 files changed

+956
-468
lines changed

18 files changed

+956
-468
lines changed

packages/browser/playwright/mocked/utils/posthog-playwright-test-base.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const lazyLoadedJSFiles = [
1313
'tracing-headers',
1414
'web-vitals',
1515
'dead-clicks-autocapture',
16+
'autocapture',
1617
]
1718

1819
export type WindowWithPostHog = typeof globalThis & {

packages/browser/rollup.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ const plugins = (es5, noExternal) => [
177177
// set as part of lazy-loading (doesn't start with _ BUT be abundantly cautious)
178178
'loadExternalDependency',
179179

180+
// called across main bundle / lazy bundle boundary for autocapture
181+
'_captureEvent',
182+
180183
// part of the public API (none start with _ so are not mangled anyway BUT be abundantly cautious)
181184
'capture',
182185
'identify',

packages/browser/src/__tests__/autocapture-utils.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
getElementsChainString,
1515
getClassNames,
1616
makeSafeText,
17-
} from '../autocapture-utils'
17+
} from '../extensions/autocapture/autocapture-utils'
1818
import { document } from '../utils/globals'
1919
import { makeMouseEvent } from './autocapture.test'
2020
import { AutocaptureConfig } from '../types'

packages/browser/src/__tests__/autocapture.test.ts

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
/// <reference lib="dom" />
22
/* eslint-disable compat/compat */
33

4+
import { Autocapture } from '../extensions/autocapture'
45
import {
5-
Autocapture,
66
getAugmentPropertiesFromElement,
77
getDefaultProperties,
88
getPropertiesFromElement,
99
previousElementSibling,
10-
} from '../autocapture'
11-
import { shouldCaptureDomEvent } from '../autocapture-utils'
10+
} from '../extensions/autocapture/external'
11+
import { shouldCaptureDomEvent } from '../extensions/autocapture/autocapture-utils'
1212
import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from '../constants'
1313
import { AutocaptureConfig, FlagsResponse, PostHogConfig, RageclickConfig } from '../types'
1414
import { PostHog } from '../posthog-core'
15-
import { window } from '../utils/globals'
15+
import { assignableWindow, window } from '../utils/globals'
1616
import { createPosthogInstance } from './helpers/posthog-instance'
1717
import { uuidv7 } from '../uuidv7'
1818
import { isUndefined } from '@posthog/core'
1919

20+
// Import the lazy-loaded implementation and register it on the window
21+
// This simulates what happens when the external script is loaded
22+
import '../entrypoints/autocapture'
23+
2024
// JS DOM doesn't have ClipboardEvent, so we need to mock it
2125
// see https://github.com/jsdom/jsdom/issues/1568
2226
class MockClipboardEvent extends Event implements ClipboardEvent {
@@ -76,6 +80,15 @@ describe('Autocapture system', () => {
7680
value: new URL('https://example.com'),
7781
})
7882

83+
// Mock loadExternalDependency to call the callback immediately
84+
// The initAutocapture function is already registered via the import above
85+
assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
86+
assignableWindow.__PosthogExtensions__.loadExternalDependency = jest
87+
.fn()
88+
.mockImplementation((_ph: PostHog, _name: string, cb: (err?: Error) => void) => {
89+
cb()
90+
})
91+
7992
beforeSendMock = jest.fn().mockImplementation((...args) => args)
8093

8194
posthog = await createPosthogInstance(uuidv7(), {
@@ -1181,8 +1194,6 @@ describe('Autocapture system', () => {
11811194
describe('afterFlagsResponse()', () => {
11821195
beforeEach(() => {
11831196
document.title = 'test page'
1184-
1185-
jest.spyOn(autocapture, '_addDomEventHandlers')
11861197
})
11871198

11881199
it('should not be enabled before the flags response', () => {
@@ -1222,32 +1233,34 @@ describe('Autocapture system', () => {
12221233
}
12231234
)
12241235

1225-
it('should call _addDomEventHandlders if autocapture is true in client config', () => {
1236+
it('should initialize handlers if autocapture is true in client config', () => {
12261237
posthog.config.autocapture = true
12271238
autocapture.onRemoteConfig({} as FlagsResponse)
1228-
expect(autocapture['_addDomEventHandlers']).toHaveBeenCalled()
1239+
expect(autocapture['_initialized']).toBe(true)
12291240
})
12301241

1231-
it('should not call _addDomEventHandlders if autocapture is opted out in server config', () => {
1242+
it('should initialize handlers even if autocapture is opted out in server config', () => {
12321243
autocapture.onRemoteConfig({ autocapture_opt_out: true } as FlagsResponse)
1233-
expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled()
1244+
expect(autocapture['_initialized']).toBe(true)
12341245
})
12351246

1236-
it('should not call _addDomEventHandlders if autocapture is disabled in client config', () => {
1237-
expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled()
1247+
it('should not initialize handlers if autocapture is disabled in client config', () => {
1248+
autocapture['_initialized'] = false
12381249
posthog.config.autocapture = false
12391250

12401251
autocapture.onRemoteConfig({} as FlagsResponse)
12411252

1242-
expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled()
1253+
expect(autocapture['_initialized']).toBe(false)
12431254
})
12441255

1245-
it('should NOT call _addDomEventHandlders when the token has already been initialized', () => {
1246-
autocapture.onRemoteConfig({} as FlagsResponse)
1247-
expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1)
1256+
it('should only initialize handlers once', () => {
1257+
const spy = jest.spyOn(autocapture as any, '_addDomEventHandlers')
1258+
autocapture['_initialized'] = false
12481259

12491260
autocapture.onRemoteConfig({} as FlagsResponse)
1250-
expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1)
1261+
autocapture.onRemoteConfig({} as FlagsResponse)
1262+
1263+
expect(spy).toHaveBeenCalledTimes(1)
12511264
})
12521265
})
12531266

packages/browser/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { PostHog } from '../../posthog-core'
22
import LazyLoadedDeadClicksAutocapture from '../../entrypoints/dead-clicks-autocapture'
33
import { assignableWindow, document } from '../../utils/globals'
4-
import { autocaptureCompatibleElements } from '../../autocapture-utils'
4+
import { autocaptureCompatibleElements } from '../../extensions/autocapture/autocapture-utils'
55

66
// need to fake the timer before jsdom inits
77
jest.useFakeTimers()
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { PostHog } from '../../posthog-core'
2+
import { assignableWindow, document } from '../../utils/globals'
3+
import { Autocapture } from '../../extensions/autocapture'
4+
import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from '../../constants'
5+
6+
describe('Autocapture wrapper', () => {
7+
let mockCaptureEvent: jest.Mock
8+
let loadCallback: ((err?: Error) => void) | null = null
9+
10+
const createMockInstance = (overrides: Partial<PostHog> = {}): PostHog => {
11+
return {
12+
config: {
13+
autocapture: true,
14+
...overrides.config,
15+
},
16+
persistence: {
17+
props: {
18+
[AUTOCAPTURE_DISABLED_SERVER_SIDE]: false,
19+
},
20+
register: jest.fn(),
21+
},
22+
_shouldDisableFlags: () => false,
23+
capture: jest.fn(),
24+
...overrides,
25+
} as unknown as PostHog
26+
}
27+
28+
beforeEach(() => {
29+
mockCaptureEvent = jest.fn()
30+
loadCallback = null
31+
32+
assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
33+
// Don't set initAutocapture initially - simulate script not loaded yet
34+
assignableWindow.__PosthogExtensions__.initAutocapture = undefined
35+
36+
// Mock loadExternalDependency to capture the callback instead of calling it immediately
37+
assignableWindow.__PosthogExtensions__.loadExternalDependency = jest
38+
.fn()
39+
.mockImplementation((_ph: PostHog, _name: string, cb: (err?: Error) => void) => {
40+
loadCallback = cb
41+
})
42+
})
43+
44+
const completeScriptLoad = () => {
45+
// Simulate the script loading and registering initAutocapture
46+
assignableWindow.__PosthogExtensions__.initAutocapture = jest.fn().mockReturnValue({
47+
_captureEvent: mockCaptureEvent,
48+
setElementSelectors: jest.fn(),
49+
getElementSelectors: jest.fn(),
50+
})
51+
loadCallback?.()
52+
}
53+
54+
afterEach(() => {
55+
document.getElementsByTagName('html')[0].innerHTML = ''
56+
})
57+
58+
describe('event queuing', () => {
59+
it('queues events before lazy load completes', () => {
60+
const instance = createMockInstance()
61+
const autocapture = new Autocapture(instance)
62+
63+
// Simulate server enabling autocapture
64+
autocapture.onRemoteConfig({ autocapture_opt_out: false } as any)
65+
66+
// Access the private queue for testing
67+
expect((autocapture as any)._eventQueue).toHaveLength(0)
68+
69+
// Simulate click events before lazy load completes
70+
const button = document.createElement('button')
71+
document.body.appendChild(button)
72+
73+
const clickEvent = new MouseEvent('click', { bubbles: true })
74+
;(autocapture as any)._captureEvent(clickEvent)
75+
76+
// Events should be queued
77+
expect((autocapture as any)._eventQueue).toHaveLength(1)
78+
expect((autocapture as any)._eventQueue[0].event).toBe(clickEvent)
79+
80+
// Lazy-loaded implementation should not have been called yet
81+
expect(mockCaptureEvent).not.toHaveBeenCalled()
82+
})
83+
84+
it('processes queued events when lazy load completes', () => {
85+
const instance = createMockInstance()
86+
const autocapture = new Autocapture(instance)
87+
88+
autocapture.onRemoteConfig({ autocapture_opt_out: false } as any)
89+
90+
// Queue some events
91+
const event1 = new MouseEvent('click', { bubbles: true })
92+
const event2 = new MouseEvent('click', { bubbles: true })
93+
;(autocapture as any)._captureEvent(event1)
94+
;(autocapture as any)._captureEvent(event2)
95+
96+
expect((autocapture as any)._eventQueue).toHaveLength(2)
97+
98+
// Complete the lazy load
99+
completeScriptLoad()
100+
101+
// Queue should be empty now
102+
expect((autocapture as any)._eventQueue).toHaveLength(0)
103+
104+
// Events should have been processed with correct timestamps
105+
expect(mockCaptureEvent).toHaveBeenCalledTimes(2)
106+
expect(mockCaptureEvent).toHaveBeenNthCalledWith(1, event1, undefined, expect.any(Date))
107+
expect(mockCaptureEvent).toHaveBeenNthCalledWith(2, event2, undefined, expect.any(Date))
108+
})
109+
110+
it('limits queue size to prevent unbounded growth', () => {
111+
const instance = createMockInstance()
112+
const autocapture = new Autocapture(instance)
113+
114+
autocapture.onRemoteConfig({ autocapture_opt_out: false } as any)
115+
116+
// Try to queue more than MAX_QUEUED_EVENTS (1000)
117+
for (let i = 0; i < 1100; i++) {
118+
const event = new MouseEvent('click', { bubbles: true })
119+
;(autocapture as any)._captureEvent(event)
120+
}
121+
122+
// Queue should be capped at 1000
123+
expect((autocapture as any)._eventQueue).toHaveLength(1000)
124+
})
125+
126+
it('passes events directly to lazy implementation after load', () => {
127+
const instance = createMockInstance()
128+
const autocapture = new Autocapture(instance)
129+
130+
autocapture.onRemoteConfig({ autocapture_opt_out: false } as any)
131+
132+
// Complete lazy load first
133+
completeScriptLoad()
134+
135+
// Now capture an event
136+
const event = new MouseEvent('click', { bubbles: true })
137+
;(autocapture as any)._captureEvent(event)
138+
139+
// Should go directly to lazy implementation, not queue
140+
expect((autocapture as any)._eventQueue).toHaveLength(0)
141+
expect(mockCaptureEvent).toHaveBeenCalledWith(event, undefined)
142+
})
143+
144+
it('does not queue events if autocapture is disabled', () => {
145+
const instance = createMockInstance({
146+
config: { autocapture: false } as any,
147+
})
148+
const autocapture = new Autocapture(instance)
149+
150+
const event = new MouseEvent('click', { bubbles: true })
151+
;(autocapture as any)._captureEvent(event)
152+
153+
expect((autocapture as any)._eventQueue).toHaveLength(0)
154+
expect(mockCaptureEvent).not.toHaveBeenCalled()
155+
})
156+
})
157+
})

0 commit comments

Comments
 (0)