Skip to content

Commit 0e988cc

Browse files
author
Eunjae Lee
authored
fix(voiceSearch): remove event listeners on dispose (#3779)
* fix(voiceSearch): remove event listeners on dispose * test(voiceSearch): remove unnecessary wrapper method * chore(voiceSearch): add comment * test(voiceSearch): clean up duplicated code * fix(voiceSearch): stop listening on disposal * type(voiceSearch): add return type * chore(voiceSearch): add comments * fix(voiceSearch): remove resetState on disposal * chore(voiceSearch): rename voiceSearchHelper to createVoiceSearchHelper * chore(voiceSearch): rename titles of tests * test(voiceSearch): rename listeners and remove redundant comments
1 parent fa074f2 commit 0e988cc

File tree

4 files changed

+139
-89
lines changed

4 files changed

+139
-89
lines changed

src/connectors/voice-search/__tests__/connectVoiceSearch-test.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ jest.mock('../../../lib/voiceSearchHelper', () => {
88
isBrowserSupported: () => true,
99
isListening: () => false,
1010
toggleListening: () => {},
11+
dispose: jest.fn(),
1112
// ⬇️ for test
1213
changeState: () => onStateChange(),
1314
changeQuery: query => onQueryChange(query),
@@ -77,12 +78,19 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/voice-searc
7778
);
7879
});
7980

80-
it('calls unmount on dispose', () => {
81+
it('calls unmount on `dispose`', () => {
8182
const { unmountFn, widget, helper } = getDefaultSetup();
8283
widget.init({ helper });
8384
widget.dispose({ helper, state: helper.state });
8485
expect(unmountFn).toHaveBeenCalledTimes(1);
8586
});
87+
88+
it('removes event listeners on `dispose`', () => {
89+
const { widget, helper } = getDefaultSetup();
90+
widget.init({ helper });
91+
widget.dispose({ helper, state: helper.state });
92+
expect(widget._voiceSearchHelper.dispose).toHaveBeenCalledTimes(1);
93+
});
8694
});
8795

8896
it('triggers render when state changes', () => {

src/connectors/voice-search/connectVoiceSearch.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
noop,
55
} from '../../lib/utils';
66
import { Renderer, RenderOptions, WidgetFactory } from '../../types';
7-
import voiceSearchHelper, {
7+
import createVoiceSearchHelper, {
88
VoiceListeningState,
99
ToggleListening,
1010
} from '../../lib/voiceSearchHelper';
@@ -91,7 +91,7 @@ const connectVoiceSearch: VoiceSearchConnector = (
9191
};
9292
return setQueryAndSearch;
9393
})();
94-
(this as any)._voiceSearchHelper = voiceSearchHelper({
94+
(this as any)._voiceSearchHelper = createVoiceSearchHelper({
9595
searchAsYouSpeak,
9696
onQueryChange: query => (this as any)._refine(query),
9797
onStateChange: () => {
@@ -116,6 +116,7 @@ const connectVoiceSearch: VoiceSearchConnector = (
116116
});
117117
},
118118
dispose({ state }) {
119+
(this as any)._voiceSearchHelper.dispose();
119120
unmountFn();
120121
return state.setQuery('');
121122
},
Lines changed: 74 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,4 @@
1-
import createVoiceSearchHelper, {
2-
VoiceSearchHelper,
3-
VoiceSearchHelperParams,
4-
} from '..';
5-
6-
const getVoiceSearchHelper = (
7-
opts?: VoiceSearchHelperParams
8-
): VoiceSearchHelper =>
9-
createVoiceSearchHelper(
10-
opts || {
11-
searchAsYouSpeak: false,
12-
onQueryChange: () => {},
13-
onStateChange: () => {},
14-
}
15-
);
1+
import createVoiceSearchHelper from '..';
162

173
type DummySpeechRecognition = () => void;
184
declare global {
@@ -22,14 +8,35 @@ declare global {
228
}
239
}
2410

11+
const start = jest.fn();
12+
const stop = jest.fn();
13+
14+
const createFakeSpeechRecognition = (): jest.Mock => {
15+
const simulateListener: any = {};
16+
const mock = jest.fn().mockImplementation(() => ({
17+
start,
18+
stop,
19+
addEventListener(eventName: string, callback: () => void) {
20+
simulateListener[eventName] = callback;
21+
},
22+
removeEventListener() {},
23+
}));
24+
(mock as any).simulateListener = simulateListener;
25+
return mock;
26+
};
27+
2528
describe('VoiceSearchHelper', () => {
26-
beforeEach(() => {
29+
afterEach(() => {
2730
delete window.webkitSpeechRecognition;
2831
delete window.SpeechRecognition;
2932
});
3033

3134
it('has initial state correctly', () => {
32-
const voiceSearchHelper = getVoiceSearchHelper();
35+
const voiceSearchHelper = createVoiceSearchHelper({
36+
searchAsYouSpeak: false,
37+
onQueryChange: () => {},
38+
onStateChange: () => {},
39+
});
3340
expect(voiceSearchHelper.getState()).toEqual({
3441
errorCode: undefined,
3542
isSpeechFinal: false,
@@ -39,38 +46,49 @@ describe('VoiceSearchHelper', () => {
3946
});
4047

4148
it('is not supported', () => {
42-
const voiceSearchHelper = getVoiceSearchHelper();
49+
const voiceSearchHelper = createVoiceSearchHelper({
50+
searchAsYouSpeak: false,
51+
onQueryChange: () => {},
52+
onStateChange: () => {},
53+
});
4354
expect(voiceSearchHelper.isBrowserSupported()).toBe(false);
4455
});
4556

4657
it('is not listening', () => {
47-
const voiceSearchHelper = getVoiceSearchHelper();
58+
const voiceSearchHelper = createVoiceSearchHelper({
59+
searchAsYouSpeak: false,
60+
onQueryChange: () => {},
61+
onStateChange: () => {},
62+
});
4863
expect(voiceSearchHelper.isListening()).toBe(false);
4964
});
5065

5166
it('is supported with webkitSpeechRecognition', () => {
5267
window.webkitSpeechRecognition = () => {};
53-
const voiceSearchHelper = getVoiceSearchHelper();
68+
const voiceSearchHelper = createVoiceSearchHelper({
69+
searchAsYouSpeak: false,
70+
onQueryChange: () => {},
71+
onStateChange: () => {},
72+
});
5473
expect(voiceSearchHelper.isBrowserSupported()).toBe(true);
5574
});
5675

5776
it('is supported with SpeechRecognition', () => {
58-
window.SpeechRecognition = () => {};
59-
const voiceSearchHelper = getVoiceSearchHelper();
77+
window.SpeechRecognition = createFakeSpeechRecognition();
78+
const voiceSearchHelper = createVoiceSearchHelper({
79+
searchAsYouSpeak: false,
80+
onQueryChange: () => {},
81+
onStateChange: () => {},
82+
});
6083
expect(voiceSearchHelper.isBrowserSupported()).toBe(true);
6184
});
6285

6386
it('works with mock SpeechRecognition (searchAsYouSpeak:false)', () => {
64-
let recognition;
65-
window.SpeechRecognition = jest.fn().mockImplementation(() => ({
66-
start() {
67-
/* eslint-disable-next-line consistent-this */
68-
recognition = this;
69-
},
70-
}));
87+
window.SpeechRecognition = createFakeSpeechRecognition();
88+
const { simulateListener } = window.SpeechRecognition as any;
7189
const onQueryChange = jest.fn();
7290
const onStateChange = jest.fn();
73-
const voiceSearchHelper = getVoiceSearchHelper({
91+
const voiceSearchHelper = createVoiceSearchHelper({
7492
searchAsYouSpeak: false,
7593
onQueryChange,
7694
onStateChange,
@@ -79,9 +97,9 @@ describe('VoiceSearchHelper', () => {
7997
voiceSearchHelper.toggleListening();
8098
expect(onStateChange).toHaveBeenCalledTimes(1);
8199
expect(voiceSearchHelper.getState().status).toEqual('askingPermission');
82-
recognition.onstart();
100+
simulateListener.start();
83101
expect(voiceSearchHelper.getState().status).toEqual('waiting');
84-
recognition.onresult({
102+
simulateListener.result({
85103
results: [
86104
(() => {
87105
const obj = [
@@ -98,22 +116,17 @@ describe('VoiceSearchHelper', () => {
98116
expect(voiceSearchHelper.getState().transcript).toEqual('Hello World');
99117
expect(voiceSearchHelper.getState().isSpeechFinal).toBe(true);
100118
expect(onQueryChange).toHaveBeenCalledTimes(0);
101-
recognition.onend();
119+
simulateListener.end();
102120
expect(onQueryChange).toHaveBeenCalledWith('Hello World');
103121
expect(voiceSearchHelper.getState().status).toEqual('finished');
104122
});
105123

106124
it('works with mock SpeechRecognition (searchAsYouSpeak:true)', () => {
107-
let recognition;
108-
window.SpeechRecognition = jest.fn().mockImplementation(() => ({
109-
start() {
110-
/* eslint-disable-next-line consistent-this */
111-
recognition = this;
112-
},
113-
}));
125+
window.SpeechRecognition = createFakeSpeechRecognition();
126+
const { simulateListener } = window.SpeechRecognition as any;
114127
const onQueryChange = jest.fn();
115128
const onStateChange = jest.fn();
116-
const voiceSearchHelper = getVoiceSearchHelper({
129+
const voiceSearchHelper = createVoiceSearchHelper({
117130
searchAsYouSpeak: true,
118131
onQueryChange,
119132
onStateChange,
@@ -122,9 +135,9 @@ describe('VoiceSearchHelper', () => {
122135
voiceSearchHelper.toggleListening();
123136
expect(onStateChange).toHaveBeenCalledTimes(1);
124137
expect(voiceSearchHelper.getState().status).toEqual('askingPermission');
125-
recognition.onstart();
138+
simulateListener.start();
126139
expect(voiceSearchHelper.getState().status).toEqual('waiting');
127-
recognition.onresult({
140+
simulateListener.result({
128141
results: [
129142
(() => {
130143
const obj = [
@@ -141,34 +154,41 @@ describe('VoiceSearchHelper', () => {
141154
expect(voiceSearchHelper.getState().transcript).toEqual('Hello World');
142155
expect(voiceSearchHelper.getState().isSpeechFinal).toBe(true);
143156
expect(onQueryChange).toHaveBeenCalledWith('Hello World');
144-
recognition.onend();
157+
simulateListener.end();
145158
expect(onQueryChange).toHaveBeenCalledTimes(1);
146159
expect(voiceSearchHelper.getState().status).toEqual('finished');
147160
});
148161

149162
it('works with onerror', () => {
150-
let recognition;
151-
window.SpeechRecognition = jest.fn().mockImplementation(() => ({
152-
start() {
153-
/* eslint-disable-next-line consistent-this */
154-
recognition = this;
155-
},
156-
}));
163+
window.SpeechRecognition = createFakeSpeechRecognition();
164+
const { simulateListener } = window.SpeechRecognition as any;
157165
const onQueryChange = jest.fn();
158166
const onStateChange = jest.fn();
159-
const voiceSearchHelper = getVoiceSearchHelper({
167+
const voiceSearchHelper = createVoiceSearchHelper({
160168
searchAsYouSpeak: true,
161169
onQueryChange,
162170
onStateChange,
163171
});
164172
voiceSearchHelper.toggleListening();
165173
expect(voiceSearchHelper.getState().status).toEqual('askingPermission');
166-
recognition.onerror({
174+
simulateListener.error({
167175
error: 'not-allowed',
168176
});
169177
expect(voiceSearchHelper.getState().status).toEqual('error');
170178
expect(voiceSearchHelper.getState().errorCode).toEqual('not-allowed');
171-
recognition.onend();
179+
simulateListener.end();
172180
expect(onQueryChange).toHaveBeenCalledTimes(0);
173181
});
182+
183+
it('stops listening on `dispose`', () => {
184+
window.SpeechRecognition = createFakeSpeechRecognition();
185+
const voiceSearchHelper = createVoiceSearchHelper({
186+
searchAsYouSpeak: false,
187+
onQueryChange: () => {},
188+
onStateChange: () => {},
189+
});
190+
voiceSearchHelper.toggleListening();
191+
voiceSearchHelper.dispose();
192+
expect(stop).toHaveBeenCalledTimes(1);
193+
});
174194
});

src/lib/voiceSearchHelper/index.ts

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ export type VoiceSearchHelper = {
2323
isBrowserSupported: () => boolean;
2424
isListening: () => boolean;
2525
toggleListening: () => void;
26+
dispose: () => void;
2627
};
2728

2829
export type ToggleListening = () => void;
2930

30-
export default function voiceSearchHelper({
31+
export default function createVoiceSearchHelper({
3132
searchAsYouSpeak,
3233
onQueryChange,
3334
onStateChange,
@@ -62,6 +63,40 @@ export default function voiceSearchHelper({
6263
setState(getDefaultState(status));
6364
};
6465

66+
const onStart = (): void => {
67+
setState({
68+
status: STATUS_WAITING,
69+
});
70+
};
71+
72+
const onError = (event: SpeechRecognitionError): void => {
73+
setState({ status: STATUS_ERROR, errorCode: event.error });
74+
};
75+
76+
const onResult = (event: SpeechRecognitionEvent): void => {
77+
setState({
78+
status: STATUS_RECOGNIZING,
79+
transcript:
80+
(event.results[0] &&
81+
event.results[0][0] &&
82+
event.results[0][0].transcript) ||
83+
'',
84+
isSpeechFinal: event.results[0] && event.results[0].isFinal,
85+
});
86+
if (searchAsYouSpeak && state.transcript) {
87+
onQueryChange(state.transcript);
88+
}
89+
};
90+
91+
const onEnd = (): void => {
92+
if (!state.errorCode && state.transcript && !searchAsYouSpeak) {
93+
onQueryChange(state.transcript);
94+
}
95+
if (state.status !== STATUS_ERROR) {
96+
setState({ status: STATUS_FINISHED });
97+
}
98+
};
99+
65100
const stop = (): void => {
66101
if (recognition) {
67102
recognition.stop();
@@ -77,40 +112,25 @@ export default function voiceSearchHelper({
77112
}
78113
resetState(STATUS_ASKING_PERMISSION);
79114
recognition.interimResults = true;
80-
recognition.onstart = () => {
81-
setState({
82-
status: STATUS_WAITING,
83-
});
84-
};
85-
recognition.onerror = (event: SpeechRecognitionError) => {
86-
setState({ status: STATUS_ERROR, errorCode: event.error });
87-
};
88-
recognition.onresult = (event: SpeechRecognitionEvent) => {
89-
setState({
90-
status: STATUS_RECOGNIZING,
91-
transcript:
92-
(event.results[0] &&
93-
event.results[0][0] &&
94-
event.results[0][0].transcript) ||
95-
'',
96-
isSpeechFinal: event.results[0] && event.results[0].isFinal,
97-
});
98-
if (searchAsYouSpeak && state.transcript) {
99-
onQueryChange(state.transcript);
100-
}
101-
};
102-
recognition.onend = () => {
103-
if (!state.errorCode && state.transcript && !searchAsYouSpeak) {
104-
onQueryChange(state.transcript);
105-
}
106-
if (state.status !== STATUS_ERROR) {
107-
setState({ status: STATUS_FINISHED });
108-
}
109-
};
110-
115+
recognition.addEventListener('start', onStart);
116+
recognition.addEventListener('error', onError);
117+
recognition.addEventListener('result', onResult);
118+
recognition.addEventListener('end', onEnd);
111119
recognition.start();
112120
};
113121

122+
const dispose = (): void => {
123+
if (!recognition) {
124+
return;
125+
}
126+
recognition.stop();
127+
recognition.removeEventListener('start', onStart);
128+
recognition.removeEventListener('error', onError);
129+
recognition.removeEventListener('result', onResult);
130+
recognition.removeEventListener('end', onEnd);
131+
recognition = undefined;
132+
};
133+
114134
const toggleListening = (): void => {
115135
if (!isBrowserSupported()) {
116136
return;
@@ -127,5 +147,6 @@ export default function voiceSearchHelper({
127147
isBrowserSupported,
128148
isListening,
129149
toggleListening,
150+
dispose,
130151
};
131152
}

0 commit comments

Comments
 (0)