Skip to content

Commit 503bea2

Browse files
authored
fix unload detection (#613)
Co-authored-by: @agouil
1 parent 6d51d38 commit 503bea2

File tree

9 files changed

+113
-13
lines changed

9 files changed

+113
-13
lines changed

.changeset/lucky-camels-bathe.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@segment/analytics-next': patch
3+
---
4+
5+
fix change detection bug and add ability to detect tab focus loss events

packages/browser/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"size-limit": [
4444
{
4545
"path": "dist/umd/index.js",
46-
"limit": "25.95 KB"
46+
"limit": "26.02 KB"
4747
}
4848
],
4949
"dependencies": {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { onPageLeave } from '../on-page-leave'
2+
3+
const helpers = {
4+
dispatchEvent(event: 'pagehide' | 'visibilitychange') {
5+
document.dispatchEvent(new Event(event))
6+
},
7+
setVisibilityState(state: DocumentVisibilityState) {
8+
Object.defineProperty(document, 'visibilityState', {
9+
value: state,
10+
writable: true,
11+
})
12+
},
13+
dispatchVisChangeEvent(state: DocumentVisibilityState) {
14+
helpers.setVisibilityState(state)
15+
helpers.dispatchEvent('visibilitychange')
16+
},
17+
dispatchPageHideEvent() {
18+
helpers.dispatchEvent('pagehide')
19+
},
20+
}
21+
22+
beforeEach(() => {
23+
helpers.setVisibilityState('visible')
24+
})
25+
26+
describe('onPageLeave', () => {
27+
test('callback should fire on pagehide', () => {
28+
const cb = jest.fn()
29+
onPageLeave(cb)
30+
helpers.dispatchPageHideEvent()
31+
expect(cb).toBeCalledTimes(1)
32+
})
33+
34+
test('callback should fire if document becomes hidden', () => {
35+
const cb = jest.fn()
36+
onPageLeave(cb)
37+
helpers.dispatchVisChangeEvent('hidden')
38+
expect(cb).toBeCalledTimes(1)
39+
})
40+
41+
test('callback should *not* fire if document becomes visible', () => {
42+
const cb = jest.fn()
43+
onPageLeave(cb)
44+
helpers.dispatchVisChangeEvent('visible')
45+
expect(cb).not.toBeCalled()
46+
})
47+
48+
test('if both event handlers fire, callback should still fire only once', () => {
49+
const cb = jest.fn()
50+
onPageLeave(cb)
51+
helpers.dispatchVisChangeEvent('hidden')
52+
helpers.dispatchPageHideEvent()
53+
expect(cb).toBeCalledTimes(1)
54+
})
55+
56+
test('if user leaves a tab, returns, and leaves again, callback should be called on each departure', () => {
57+
const cb = jest.fn()
58+
onPageLeave(cb)
59+
helpers.dispatchVisChangeEvent('hidden')
60+
helpers.dispatchVisChangeEvent('visible')
61+
helpers.dispatchVisChangeEvent('hidden')
62+
expect(cb).toBeCalledTimes(2)
63+
})
64+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Register event listener on document that fires when:
3+
* * tab change or tab close (in mobile or desktop)
4+
* * click back / forward button
5+
* * click any link or perform any other navigation action
6+
* * soft refresh / hard refresh
7+
*
8+
* adapted from https://stackoverflow.com/questions/3239834/window-onbeforeunload-not-working-on-the-ipad/52864508#52864508,
9+
*/
10+
export const onPageLeave = (cb: (...args: unknown[]) => void) => {
11+
let unloaded = false // prevents double firing if both are supported
12+
13+
// using document instead of window because of bug affecting browsers before safari 14 (detail in footnotes https://caniuse.com/?search=visibilitychange)
14+
document.addEventListener('pagehide', () => {
15+
if (unloaded) return
16+
unloaded = true
17+
cb()
18+
})
19+
20+
document.addEventListener('visibilitychange', () => {
21+
if (document.visibilityState == 'hidden') {
22+
if (unloaded) return
23+
unloaded = true
24+
cb()
25+
} else {
26+
unloaded = false
27+
}
28+
})
29+
}

packages/browser/src/lib/priority-queue/__tests__/persisted.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ describe('Persisted Priority Queue', () => {
4141
let onUnload: any = jest.fn()
4242

4343
jest
44-
.spyOn(window, 'addEventListener')
44+
.spyOn(document, 'addEventListener')
4545
.mockImplementation((evt, handler) => {
46-
if (evt === 'beforeunload') {
46+
if (evt === 'pagehide') {
4747
onUnload = handler
4848
}
4949
})
@@ -102,9 +102,9 @@ describe('Persisted Priority Queue', () => {
102102
let onUnload: any = jest.fn()
103103

104104
jest
105-
.spyOn(window, 'addEventListener')
105+
.spyOn(document, 'addEventListener')
106106
.mockImplementation((evt, handler) => {
107-
if (evt === 'beforeunload') {
107+
if (evt === 'pagehide') {
108108
onUnload = handler
109109
}
110110
})
@@ -200,9 +200,9 @@ describe('Persisted Priority Queue', () => {
200200
const onUnloadFunctions: any[] = []
201201

202202
jest
203-
.spyOn(window, 'addEventListener')
203+
.spyOn(document, 'addEventListener')
204204
.mockImplementation((evt, handler) => {
205-
if (evt === 'beforeunload') {
205+
if (evt === 'pagehide') {
206206
onUnloadFunctions.push(handler)
207207
}
208208
})

packages/browser/src/lib/priority-queue/persisted.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ export class PersistedPriorityQueue extends PriorityQueue<Context> {
109109
}
110110
})
111111

112-
window.addEventListener('beforeunload', () => {
112+
document.addEventListener('pagehide', () => {
113+
// we deliberately want to use the less powerful 'pagehide' API to only persist on events where the analytics instance gets destroyed, and not on tab away.
113114
if (this.todo > 0) {
114115
const items = [...this.queue, ...this.future]
115116
try {

packages/browser/src/plugins/segmentio/__tests__/batched-dispatcher.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ describe('Batching', () => {
224224

225225
expect(fetch).not.toHaveBeenCalled()
226226

227-
window.dispatchEvent(new Event('beforeunload'))
227+
document.dispatchEvent(new Event('pagehide'))
228228

229229
expect(fetch).toHaveBeenCalledTimes(1)
230230

@@ -253,7 +253,7 @@ describe('Batching', () => {
253253

254254
expect(fetch).not.toHaveBeenCalled()
255255

256-
window.dispatchEvent(new Event('beforeunload'))
256+
document.dispatchEvent(new Event('pagehide'))
257257

258258
expect(fetch).toHaveBeenCalledTimes(2)
259259
})

packages/browser/src/plugins/segmentio/batched-dispatcher.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import unfetch from 'unfetch'
22
import { SegmentEvent } from '../../core/events'
3+
import { onPageLeave } from '../../lib/on-page-leave'
34

45
let fetch = unfetch
56
if (typeof window !== 'undefined') {
@@ -92,7 +93,7 @@ export default function batch(apiHost: string, config?: BatchingConfig) {
9293
}, timeout)
9394
}
9495

95-
window.addEventListener('beforeunload', () => {
96+
onPageLeave(() => {
9697
pageUnloaded = true
9798

9899
if (buffer.length) {

packages/core/src/priority-queue/persisted.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { PriorityQueue } from '.'
22
import { CoreContext, SerializedContext } from '../context'
33

4+
// TODO: Move this back into browser
45
const nullStorage = (): Storage => ({
56
getItem: () => null,
67
setItem: () => null,
@@ -118,8 +119,7 @@ export class PersistedPriorityQueue extends PriorityQueue<CoreContext> {
118119
console.error(err)
119120
}
120121
})
121-
122-
window.addEventListener('beforeunload', () => {
122+
window.addEventListener('pagehide', () => {
123123
if (this.todo > 0) {
124124
const items = [...this.queue, ...this.future]
125125
try {

0 commit comments

Comments
 (0)