Skip to content

Commit 128e578

Browse files
authored
fix: unsubscribe correctly in onlineManager and focusManager (TanStack#3092)
1 parent 0781142 commit 128e578

File tree

4 files changed

+166
-70
lines changed

4 files changed

+166
-70
lines changed

src/core/focusManager.ts

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,51 @@
11
import { Subscribable } from './subscribable'
22
import { isServer } from './utils'
33

4-
class FocusManager extends Subscribable {
4+
type SetupFn = (
5+
setFocused: (focused?: boolean) => void
6+
) => (() => void) | undefined
7+
8+
export class FocusManager extends Subscribable {
59
private focused?: boolean
6-
private removeEventListener?: () => void
10+
private cleanup?: () => void
11+
12+
private setup: SetupFn
13+
14+
constructor() {
15+
super()
16+
this.setup = onFocus => {
17+
if (!isServer && window?.addEventListener) {
18+
const listener = () => onFocus()
19+
// Listen to visibillitychange and focus
20+
window.addEventListener('visibilitychange', listener, false)
21+
window.addEventListener('focus', listener, false)
22+
23+
return () => {
24+
// Be sure to unsubscribe if a new handler is set
25+
window.removeEventListener('visibilitychange', listener)
26+
window.removeEventListener('focus', listener)
27+
}
28+
}
29+
}
30+
}
731

832
protected onSubscribe(): void {
9-
if (!this.removeEventListener) {
10-
this.setDefaultEventListener()
33+
if (!this.cleanup) {
34+
this.setEventListener(this.setup)
1135
}
1236
}
1337

14-
setEventListener(
15-
setup: (setFocused: (focused?: boolean) => void) => () => void
16-
): void {
17-
if (this.removeEventListener) {
18-
this.removeEventListener()
38+
protected onUnsubscribe() {
39+
if (!this.hasListeners()) {
40+
this.cleanup?.()
41+
this.cleanup = undefined
1942
}
20-
this.removeEventListener = setup(focused => {
43+
}
44+
45+
setEventListener(setup: SetupFn): void {
46+
this.setup = setup
47+
this.cleanup?.()
48+
this.cleanup = setup(focused => {
2149
if (typeof focused === 'boolean') {
2250
this.setFocused(focused)
2351
} else {
@@ -54,23 +82,6 @@ class FocusManager extends Subscribable {
5482
document.visibilityState
5583
)
5684
}
57-
58-
private setDefaultEventListener() {
59-
if (!isServer && window?.addEventListener) {
60-
this.setEventListener(onFocus => {
61-
const listener = () => onFocus()
62-
// Listen to visibillitychange and focus
63-
window.addEventListener('visibilitychange', listener, false)
64-
window.addEventListener('focus', listener, false)
65-
66-
return () => {
67-
// Be sure to unsubscribe if a new handler is set
68-
window.removeEventListener('visibilitychange', listener)
69-
window.removeEventListener('focus', listener)
70-
}
71-
})
72-
}
73-
}
7485
}
7586

7687
export const focusManager = new FocusManager()

src/core/onlineManager.ts

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,51 @@
11
import { Subscribable } from './subscribable'
22
import { isServer } from './utils'
33

4-
class OnlineManager extends Subscribable {
4+
type SetupFn = (
5+
setOnline: (online?: boolean) => void
6+
) => (() => void) | undefined
7+
8+
export class OnlineManager extends Subscribable {
59
private online?: boolean
6-
private removeEventListener?: () => void
10+
private cleanup?: () => void
11+
12+
private setup: SetupFn
13+
14+
constructor() {
15+
super()
16+
this.setup = onOnline => {
17+
if (!isServer && window?.addEventListener) {
18+
const listener = () => onOnline()
19+
// Listen to online
20+
window.addEventListener('online', listener, false)
21+
window.addEventListener('offline', listener, false)
22+
23+
return () => {
24+
// Be sure to unsubscribe if a new handler is set
25+
window.removeEventListener('online', listener)
26+
window.removeEventListener('offline', listener)
27+
}
28+
}
29+
}
30+
}
731

832
protected onSubscribe(): void {
9-
if (!this.removeEventListener) {
10-
this.setDefaultEventListener()
33+
if (!this.cleanup) {
34+
this.setEventListener(this.setup)
1135
}
1236
}
1337

14-
setEventListener(
15-
setup: (setOnline: (online?: boolean) => void) => () => void
16-
): void {
17-
if (this.removeEventListener) {
18-
this.removeEventListener()
38+
protected onUnsubscribe() {
39+
if (!this.hasListeners()) {
40+
this.cleanup?.()
41+
this.cleanup = undefined
1942
}
20-
this.removeEventListener = setup((online?: boolean) => {
43+
}
44+
45+
setEventListener(setup: SetupFn): void {
46+
this.setup = setup
47+
this.cleanup?.()
48+
this.cleanup = setup((online?: boolean) => {
2149
if (typeof online === 'boolean') {
2250
this.setOnline(online)
2351
} else {
@@ -54,23 +82,6 @@ class OnlineManager extends Subscribable {
5482

5583
return navigator.onLine
5684
}
57-
58-
private setDefaultEventListener() {
59-
if (!isServer && window?.addEventListener) {
60-
this.setEventListener(onOnline => {
61-
const listener = () => onOnline()
62-
// Listen to online
63-
window.addEventListener('online', listener, false)
64-
window.addEventListener('offline', listener, false)
65-
66-
return () => {
67-
// Be sure to unsubscribe if a new handler is set
68-
window.removeEventListener('online', listener)
69-
window.removeEventListener('offline', listener)
70-
}
71-
})
72-
}
73-
}
7485
}
7586

7687
export const onlineManager = new OnlineManager()

src/core/tests/focusManager.test.tsx

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { sleep } from '../utils'
2-
import { focusManager } from '../focusManager'
2+
import { FocusManager } from '../focusManager'
33

44
describe('focusManager', () => {
5-
afterEach(() => {
6-
// Reset removeEventListener private property to avoid side effects between tests
7-
focusManager['removeEventListener'] = undefined
5+
let focusManager: FocusManager
6+
beforeEach(() => {
7+
focusManager = new FocusManager()
88
})
99

1010
it('should call previous remove handler when replacing an event listener', () => {
@@ -61,16 +61,14 @@ describe('focusManager', () => {
6161
globalThis.document = document
6262
})
6363

64-
it('should not set window listener if window.addEventListener is not defined', async () => {
64+
test('cleanup should still be undefined if window.addEventListener is not defined', async () => {
6565
const { addEventListener } = globalThis.window
6666

6767
// @ts-expect-error
6868
globalThis.window.addEventListener = undefined
6969

70-
const setEventListenerSpy = jest.spyOn(focusManager, 'setEventListener')
71-
7270
const unsubscribe = focusManager.subscribe()
73-
expect(setEventListenerSpy).toHaveBeenCalledTimes(0)
71+
expect(focusManager['cleanup']).toBeUndefined()
7472

7573
unsubscribe()
7674
globalThis.window.addEventListener = addEventListener
@@ -103,4 +101,43 @@ describe('focusManager', () => {
103101
addEventListenerSpy.mockRestore()
104102
removeEventListenerSpy.mockRestore()
105103
})
104+
105+
test('should call removeEventListener when last listener unsubscribes', () => {
106+
const addEventListenerSpy = jest.spyOn(
107+
globalThis.window,
108+
'addEventListener'
109+
)
110+
111+
const removeEventListenerSpy = jest.spyOn(
112+
globalThis.window,
113+
'removeEventListener'
114+
)
115+
116+
const unsubscribe1 = focusManager.subscribe(() => undefined)
117+
const unsubscribe2 = focusManager.subscribe(() => undefined)
118+
expect(addEventListenerSpy).toHaveBeenCalledTimes(2) // visibilitychange + focus
119+
120+
unsubscribe1()
121+
expect(removeEventListenerSpy).toHaveBeenCalledTimes(0)
122+
unsubscribe2()
123+
expect(removeEventListenerSpy).toHaveBeenCalledTimes(2) // visibilitychange + focus
124+
})
125+
126+
test('should keep setup function even if last listener unsubscribes', () => {
127+
const setupSpy = jest.fn().mockImplementation(() => () => undefined)
128+
129+
focusManager.setEventListener(setupSpy)
130+
131+
const unsubscribe1 = focusManager.subscribe(() => undefined)
132+
133+
expect(setupSpy).toHaveBeenCalledTimes(1)
134+
135+
unsubscribe1()
136+
137+
const unsubscribe2 = focusManager.subscribe(() => undefined)
138+
139+
expect(setupSpy).toHaveBeenCalledTimes(2)
140+
141+
unsubscribe2()
142+
})
106143
})

src/core/tests/onlineManager.test.tsx

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { onlineManager } from '../onlineManager'
1+
import { OnlineManager } from '../onlineManager'
22
import { sleep } from '../utils'
33

44
describe('onlineManager', () => {
5-
afterEach(() => {
6-
// Reset removeEventListener private property to avoid side effects between tests
7-
onlineManager['removeEventListener'] = undefined
5+
let onlineManager: OnlineManager
6+
beforeEach(() => {
7+
onlineManager = new OnlineManager()
88
})
99

1010
test('isOnline should return true if navigator is undefined', () => {
@@ -56,16 +56,14 @@ describe('onlineManager', () => {
5656
expect(remove2Spy).not.toHaveBeenCalled()
5757
})
5858

59-
test('setEventListener should not set window listener if window.addEventListener is not defined', async () => {
59+
test('cleanup should still be undefined if window.addEventListener is not defined', async () => {
6060
const { addEventListener } = globalThis.window
6161

6262
// @ts-expect-error
6363
globalThis.window.addEventListener = undefined
6464

65-
const setEventListenerSpy = jest.spyOn(onlineManager, 'setEventListener')
66-
6765
const unsubscribe = onlineManager.subscribe()
68-
expect(setEventListenerSpy).toHaveBeenCalledTimes(0)
66+
expect(onlineManager['cleanup']).toBeUndefined()
6967

7068
unsubscribe()
7169
globalThis.window.addEventListener = addEventListener
@@ -98,4 +96,43 @@ describe('onlineManager', () => {
9896
addEventListenerSpy.mockRestore()
9997
removeEventListenerSpy.mockRestore()
10098
})
99+
100+
test('should call removeEventListener when last listener unsubscribes', () => {
101+
const addEventListenerSpy = jest.spyOn(
102+
globalThis.window,
103+
'addEventListener'
104+
)
105+
106+
const removeEventListenerSpy = jest.spyOn(
107+
globalThis.window,
108+
'removeEventListener'
109+
)
110+
111+
const unsubscribe1 = onlineManager.subscribe(() => undefined)
112+
const unsubscribe2 = onlineManager.subscribe(() => undefined)
113+
expect(addEventListenerSpy).toHaveBeenCalledTimes(2) // online + offline
114+
115+
unsubscribe1()
116+
expect(removeEventListenerSpy).toHaveBeenCalledTimes(0)
117+
unsubscribe2()
118+
expect(removeEventListenerSpy).toHaveBeenCalledTimes(2) // online + offline
119+
})
120+
121+
test('should keep setup function even if last listener unsubscribes', () => {
122+
const setupSpy = jest.fn().mockImplementation(() => () => undefined)
123+
124+
onlineManager.setEventListener(setupSpy)
125+
126+
const unsubscribe1 = onlineManager.subscribe(() => undefined)
127+
128+
expect(setupSpy).toHaveBeenCalledTimes(1)
129+
130+
unsubscribe1()
131+
132+
const unsubscribe2 = onlineManager.subscribe(() => undefined)
133+
134+
expect(setupSpy).toHaveBeenCalledTimes(2)
135+
136+
unsubscribe2()
137+
})
101138
})

0 commit comments

Comments
 (0)