posthog-js-lite 3.4.2 → 3.5.1

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.
package/src/context.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { utils } from '../../posthog-core'
1
+ import { utils } from 'posthog-core'
2
2
  import { version } from '../package.json'
3
3
 
4
4
  export function getContext(window: Window | undefined): any {
package/src/patch.ts ADDED
@@ -0,0 +1,50 @@
1
+ // import { patch } from 'rrweb/typings/utils'
2
+ // copied from: https://github.com/PostHog/posthog-js/blob/main/src/extensions/replay/rrweb-plugins/patch.ts
3
+ // which was copied from https://github.com/rrweb-io/rrweb/blob/8aea5b00a4dfe5a6f59bd2ae72bb624f45e51e81/packages/rrweb/src/utils.ts#L129
4
+ // which was copied from https://github.com/getsentry/sentry-javascript/blob/b2109071975af8bf0316d3b5b38f519bdaf5dc15/packages/utils/src/object.ts
5
+
6
+ // copied from: https://github.com/PostHog/posthog-js/blob/main/react/src/utils/type-utils.ts#L4
7
+ export const isFunction = function (f: any): f is (...args: any[]) => any {
8
+ return typeof f === 'function'
9
+ }
10
+
11
+ export function patch(
12
+ source: { [key: string]: any },
13
+ name: string,
14
+ replacement: (...args: unknown[]) => unknown
15
+ ): () => void {
16
+ try {
17
+ if (!(name in source)) {
18
+ return () => {
19
+ //
20
+ }
21
+ }
22
+
23
+ const original = source[name] as () => unknown
24
+ const wrapped = replacement(original)
25
+
26
+ // Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work
27
+ // otherwise it'll throw "TypeError: Object.defineProperties called on non-object"
28
+ if (isFunction(wrapped)) {
29
+ wrapped.prototype = wrapped.prototype || {}
30
+ Object.defineProperties(wrapped, {
31
+ __posthog_wrapped__: {
32
+ enumerable: false,
33
+ value: true,
34
+ },
35
+ })
36
+ }
37
+
38
+ source[name] = wrapped
39
+
40
+ return () => {
41
+ source[name] = original
42
+ }
43
+ } catch {
44
+ return () => {
45
+ //
46
+ }
47
+ // This can throw if multiple fill happens on a global object like XMLHttpRequest
48
+ // Fixes https://github.com/getsentry/sentry-javascript/issues/2043
49
+ }
50
+ }
@@ -1,19 +1,18 @@
1
- import {
2
- PostHogCore,
3
- PostHogFetchOptions,
4
- PostHogFetchResponse,
5
- PostHogPersistedProperty,
6
- } from '../../posthog-core/src'
1
+ import { version } from '../package.json'
2
+
3
+ import { PostHogCore, getFetch } from 'posthog-core'
4
+ import type { PostHogFetchOptions, PostHogFetchResponse, PostHogPersistedProperty } from 'posthog-core'
5
+
7
6
  import { getContext } from './context'
8
7
  import { PostHogStorage, getStorage } from './storage'
9
- import { version } from '../package.json'
10
8
  import { PostHogOptions } from './types'
11
- import { getFetch } from 'posthog-core/src/utils'
9
+ import { patch } from './patch'
12
10
 
13
11
  export class PostHog extends PostHogCore {
14
12
  private _storage: PostHogStorage
15
13
  private _storageCache: any
16
14
  private _storageKey: string
15
+ private _lastPathname: string = ''
17
16
 
18
17
  constructor(apiKey: string, options?: PostHogOptions) {
19
18
  super(apiKey, options)
@@ -27,6 +26,11 @@ export class PostHog extends PostHogCore {
27
26
  if (options?.preloadFeatureFlags !== false) {
28
27
  this.reloadFeatureFlags()
29
28
  }
29
+
30
+ if (options?.captureHistoryEvents && typeof window !== 'undefined') {
31
+ this._lastPathname = window?.location?.pathname || ''
32
+ this.setupHistoryEventTracking()
33
+ }
30
34
  }
31
35
 
32
36
  private getWindow(): Window | undefined {
@@ -84,4 +88,49 @@ export class PostHog extends PostHogCore {
84
88
  ...getContext(this.getWindow()),
85
89
  }
86
90
  }
91
+
92
+ private setupHistoryEventTracking(): void {
93
+ const window = this.getWindow()
94
+ if (!window) {
95
+ return
96
+ }
97
+
98
+ // Old fashioned, we could also use arrow functions but I think the closure for a patch is more reliable
99
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
100
+ const self = this
101
+
102
+ patch(window.history, 'pushState', (originalPushState) => {
103
+ return function patchedPushState(this: History, state: any, title: string, url?: string | URL | null): void {
104
+ ;(originalPushState as History['pushState']).call(this, state, title, url)
105
+ self.captureNavigationEvent('pushState')
106
+ }
107
+ })
108
+
109
+ patch(window.history, 'replaceState', (originalReplaceState) => {
110
+ return function patchedReplaceState(this: History, state: any, title: string, url?: string | URL | null): void {
111
+ ;(originalReplaceState as History['replaceState']).call(this, state, title, url)
112
+ self.captureNavigationEvent('replaceState')
113
+ }
114
+ })
115
+
116
+ // For popstate we need to listen to the event instead of overriding a method
117
+ window.addEventListener('popstate', () => {
118
+ this.captureNavigationEvent('popstate')
119
+ })
120
+ }
121
+
122
+ private captureNavigationEvent(navigationType: 'pushState' | 'replaceState' | 'popstate'): void {
123
+ const window = this.getWindow()
124
+ if (!window) {
125
+ return
126
+ }
127
+
128
+ const currentPathname = window.location.pathname
129
+
130
+ // Only capture pageview if the pathname has changed
131
+ if (currentPathname !== this._lastPathname) {
132
+ this.capture('$pageview', { navigation_type: navigationType })
133
+ this._lastPathname = currentPathname
134
+ }
135
+ }
87
136
  }
package/src/types.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { PostHogCoreOptions } from '../../posthog-core/src'
1
+ import type { PostHogCoreOptions } from 'posthog-core'
2
2
 
3
3
  export type PostHogOptions = {
4
4
  autocapture?: boolean
5
5
  persistence?: 'localStorage' | 'sessionStorage' | 'cookie' | 'memory'
6
6
  persistence_name?: string
7
+ captureHistoryEvents?: boolean
7
8
  } & PostHogCoreOptions
@@ -12,7 +12,7 @@ describe('PostHogWeb', () => {
12
12
  beforeEach(() => {
13
13
  ;(global as any).window.fetch = fetch = jest.fn(async (url) => {
14
14
  let res: any = { status: 'ok' }
15
- if (url.includes('decide')) {
15
+ if (url.includes('flags')) {
16
16
  res = {
17
17
  featureFlags: {
18
18
  'feature-1': true,
@@ -61,7 +61,7 @@ describe('PostHogWeb', () => {
61
61
 
62
62
  await waitForPromises()
63
63
 
64
- expect(fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=3', {
64
+ expect(fetch).toHaveBeenCalledWith('https://us.i.posthog.com/flags/?v=2', {
65
65
  body: JSON.stringify({
66
66
  token: 'TEST_API_KEY',
67
67
  distinct_id: posthog.getDistinctId(),
@@ -95,4 +95,195 @@ describe('PostHogWeb', () => {
95
95
  })
96
96
  })
97
97
  })
98
+
99
+ describe('History API tracking', () => {
100
+ const originalPushState = window.history.pushState
101
+ const originalReplaceState = window.history.replaceState
102
+ let setPathname: (pathname: string) => void
103
+
104
+ beforeEach(() => {
105
+ const mockLocation = {
106
+ pathname: '/initial-path',
107
+ }
108
+
109
+ Object.defineProperty(window, 'location', {
110
+ value: mockLocation,
111
+ writable: true,
112
+ })
113
+
114
+ setPathname = (pathname: string) => {
115
+ mockLocation.pathname = pathname
116
+ }
117
+ })
118
+
119
+ // Reset history methods after each test
120
+ afterEach(() => {
121
+ window.history.pushState = originalPushState
122
+ window.history.replaceState = originalReplaceState
123
+
124
+ const popstateHandler = (): void => {}
125
+ window.addEventListener('popstate', popstateHandler)
126
+ window.removeEventListener('popstate', popstateHandler)
127
+ })
128
+
129
+ it('should not patch history methods when captureHistoryEvents is disabled', () => {
130
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
131
+ const posthog = new PostHog('TEST_API_KEY', {
132
+ captureHistoryEvents: false,
133
+ })
134
+
135
+ expect(window.history.pushState).toBe(originalPushState)
136
+ expect(window.history.replaceState).toBe(originalReplaceState)
137
+ })
138
+
139
+ it('should patch history methods when captureHistoryEvents is enabled', () => {
140
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
141
+ const posthog = new PostHog('TEST_API_KEY', {
142
+ captureHistoryEvents: true,
143
+ })
144
+
145
+ expect(window.history.pushState).not.toBe(originalPushState)
146
+ expect(window.history.replaceState).not.toBe(originalReplaceState)
147
+ })
148
+
149
+ it('should capture pageview events on pushState when pathname changes', async () => {
150
+ const posthog = new PostHog('TEST_API_KEY', {
151
+ captureHistoryEvents: true,
152
+ flushAt: 1,
153
+ })
154
+
155
+ const captureSpy = jest.spyOn(posthog, 'capture')
156
+
157
+ // Change pathname
158
+ setPathname('/test-page')
159
+ window.history.pushState({}, '', '/test-page')
160
+
161
+ expect(captureSpy).toHaveBeenCalledWith(
162
+ '$pageview',
163
+ expect.objectContaining({
164
+ navigation_type: 'pushState',
165
+ })
166
+ )
167
+ })
168
+
169
+ it('should not capture pageview events on pushState when pathname does not change', async () => {
170
+ const posthog = new PostHog('TEST_API_KEY', {
171
+ captureHistoryEvents: true,
172
+ flushAt: 1,
173
+ })
174
+
175
+ const captureSpy = jest.spyOn(posthog, 'capture')
176
+ captureSpy.mockClear()
177
+
178
+ // Don't change pathname
179
+ window.history.pushState({}, '', '/initial-path')
180
+
181
+ expect(captureSpy).not.toHaveBeenCalled()
182
+ })
183
+
184
+ it('should capture pageview events on replaceState when pathname changes', async () => {
185
+ const posthog = new PostHog('TEST_API_KEY', {
186
+ captureHistoryEvents: true,
187
+ flushAt: 1,
188
+ })
189
+
190
+ const captureSpy = jest.spyOn(posthog, 'capture')
191
+
192
+ // Change pathname
193
+ setPathname('/replaced-page')
194
+ window.history.replaceState({}, '', '/replaced-page')
195
+
196
+ expect(captureSpy).toHaveBeenCalledWith(
197
+ '$pageview',
198
+ expect.objectContaining({
199
+ navigation_type: 'replaceState',
200
+ })
201
+ )
202
+ })
203
+
204
+ it('should not capture pageview events on replaceState when pathname does not change', async () => {
205
+ const posthog = new PostHog('TEST_API_KEY', {
206
+ captureHistoryEvents: true,
207
+ flushAt: 1,
208
+ })
209
+
210
+ const captureSpy = jest.spyOn(posthog, 'capture')
211
+ captureSpy.mockClear()
212
+
213
+ // Don't change pathname
214
+ window.history.replaceState({}, '', '/initial-path')
215
+
216
+ expect(captureSpy).not.toHaveBeenCalled()
217
+ })
218
+
219
+ it('should capture pageview events on popstate when pathname changes', async () => {
220
+ const posthog = new PostHog('TEST_API_KEY', {
221
+ captureHistoryEvents: true,
222
+ flushAt: 1,
223
+ })
224
+
225
+ const captureSpy = jest.spyOn(posthog, 'capture')
226
+
227
+ // Change pathname
228
+ setPathname('/popstate-page')
229
+ window.dispatchEvent(new Event('popstate'))
230
+
231
+ expect(captureSpy).toHaveBeenCalledWith(
232
+ '$pageview',
233
+ expect.objectContaining({
234
+ navigation_type: 'popstate',
235
+ })
236
+ )
237
+ })
238
+
239
+ it('should not capture pageview events on popstate when pathname does not change', async () => {
240
+ const posthog = new PostHog('TEST_API_KEY', {
241
+ captureHistoryEvents: true,
242
+ flushAt: 1,
243
+ })
244
+
245
+ const captureSpy = jest.spyOn(posthog, 'capture')
246
+ captureSpy.mockClear()
247
+
248
+ // Don't change pathname
249
+ window.dispatchEvent(new Event('popstate'))
250
+
251
+ expect(captureSpy).not.toHaveBeenCalled()
252
+ })
253
+
254
+ it('should include navigation properties in capture call and rely on getCommonEventProperties', async () => {
255
+ const posthog = new PostHog('TEST_API_KEY', {
256
+ captureHistoryEvents: true,
257
+ flushAt: 1,
258
+ })
259
+
260
+ const commonEventProps = { $lib: 'posthog-js-lite', $lib_version: '1.0.0' }
261
+ const getCommonEventPropertiesSpy = jest
262
+ .spyOn(posthog, 'getCommonEventProperties')
263
+ .mockImplementation(() => commonEventProps)
264
+
265
+ const coreCaptureMethod = jest.spyOn(PostHog.prototype, 'capture')
266
+
267
+ // Change pathname
268
+ setPathname('/captured-page')
269
+ window.history.pushState({}, '', '/captured-page')
270
+
271
+ // Will use a mock here for now and rely on the implementation since the tests setup is very simple at the moment
272
+ expect(getCommonEventPropertiesSpy).toHaveBeenCalled()
273
+
274
+ const captureCall = coreCaptureMethod.mock.calls.find(
275
+ (call) => call[0] === '$pageview' && call[1] && call[1].navigation_type === 'pushState'
276
+ )
277
+
278
+ expect(captureCall).toBeDefined()
279
+
280
+ const navigationProperties = {
281
+ navigation_type: 'pushState',
282
+ }
283
+
284
+ if (captureCall) {
285
+ expect(captureCall[1]).toMatchObject(navigationProperties)
286
+ }
287
+ })
288
+ })
98
289
  })
package/tsconfig.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "extends": "../tsconfig.json",
3
3
  "compilerOptions": {
4
+ "incremental": false,
4
5
  "lib": ["DOM", "ES2020", "ES2022.Error"]
5
6
  }
6
7
  }