@@ -158,3 +158,164 @@ describe('MessageQueue', () => {
158158 expect ( queue . length ) . toBe ( 1 ) ;
159159 } ) ;
160160} ) ;
161+
162+ describe ( 'page-unload flush' , ( ) => {
163+ let sendBeaconSpy : jest . SpyInstance ;
164+
165+ beforeEach ( ( ) => {
166+ sendBeaconSpy = jest . fn ( ) . mockReturnValue ( true ) ;
167+ Object . defineProperty ( navigator , 'sendBeacon' , {
168+ value : sendBeaconSpy ,
169+ writable : true ,
170+ configurable : true ,
171+ } ) ;
172+ } ) ;
173+
174+ afterEach ( ( ) => {
175+ sendBeaconSpy . mockRestore ?.( ) ;
176+ } ) ;
177+
178+ it ( 'flushes via sendBeacon on visibilitychange to hidden' , ( ) => {
179+ const send = jest . fn ( ) . mockResolvedValue ( true ) ;
180+ const queue = createQueue ( { send } ) ;
181+ queue . start ( ) ;
182+
183+ queue . enqueue ( makeMessage ( '1' ) ) ;
184+
185+ Object . defineProperty ( document , 'visibilityState' , {
186+ value : 'hidden' ,
187+ writable : true ,
188+ configurable : true ,
189+ } ) ;
190+ document . dispatchEvent ( new Event ( 'visibilitychange' ) ) ;
191+
192+ expect ( sendBeaconSpy ) . toHaveBeenCalledTimes ( 1 ) ;
193+ expect ( sendBeaconSpy ) . toHaveBeenCalledWith (
194+ 'https://api.immutable.com/v1/audience/messages' ,
195+ expect . any ( Blob ) ,
196+ ) ;
197+ expect ( queue . length ) . toBe ( 0 ) ;
198+
199+ queue . stop ( ) ;
200+ Object . defineProperty ( document , 'visibilityState' , {
201+ value : 'visible' ,
202+ writable : true ,
203+ configurable : true ,
204+ } ) ;
205+ } ) ;
206+
207+ it ( 'flushes via sendBeacon on pagehide' , ( ) => {
208+ const send = jest . fn ( ) . mockResolvedValue ( true ) ;
209+ const queue = createQueue ( { send } ) ;
210+ queue . start ( ) ;
211+
212+ queue . enqueue ( makeMessage ( '1' ) ) ;
213+ window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
214+
215+ expect ( sendBeaconSpy ) . toHaveBeenCalledTimes ( 1 ) ;
216+ expect ( queue . length ) . toBe ( 0 ) ;
217+
218+ queue . stop ( ) ;
219+ } ) ;
220+
221+ it ( 'does not fire beacon when queue is empty' , ( ) => {
222+ const send = jest . fn ( ) . mockResolvedValue ( true ) ;
223+ const queue = createQueue ( { send } ) ;
224+ queue . start ( ) ;
225+
226+ window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
227+
228+ expect ( sendBeaconSpy ) . not . toHaveBeenCalled ( ) ;
229+
230+ queue . stop ( ) ;
231+ } ) ;
232+
233+ it ( 'removes listeners on stop' , ( ) => {
234+ const send = jest . fn ( ) . mockResolvedValue ( true ) ;
235+ const queue = createQueue ( { send } ) ;
236+ queue . start ( ) ;
237+ queue . stop ( ) ;
238+
239+ queue . enqueue ( makeMessage ( '1' ) ) ;
240+ window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
241+
242+ expect ( sendBeaconSpy ) . not . toHaveBeenCalled ( ) ;
243+ } ) ;
244+
245+ it ( 'destroy stops the queue and flushes remaining messages' , ( ) => {
246+ const send = jest . fn ( ) . mockResolvedValue ( true ) ;
247+ const queue = createQueue ( { send } ) ;
248+ queue . start ( ) ;
249+
250+ queue . enqueue ( makeMessage ( '1' ) ) ;
251+ queue . enqueue ( makeMessage ( '2' ) ) ;
252+ queue . destroy ( ) ;
253+
254+ expect ( sendBeaconSpy ) . toHaveBeenCalledTimes ( 1 ) ;
255+ expect ( queue . length ) . toBe ( 0 ) ;
256+
257+ // Listeners removed — no double flush
258+ queue . enqueue ( makeMessage ( '3' ) ) ;
259+ window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
260+ expect ( sendBeaconSpy ) . toHaveBeenCalledTimes ( 1 ) ;
261+ } ) ;
262+
263+ it ( 'falls back to async flush if sendBeacon returns false' , async ( ) => {
264+ sendBeaconSpy . mockReturnValue ( false ) ;
265+ const send = jest . fn ( ) . mockResolvedValue ( true ) ;
266+ const queue = createQueue ( { send } ) ;
267+ queue . start ( ) ;
268+
269+ queue . enqueue ( makeMessage ( '1' ) ) ;
270+ window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
271+
272+ // sendBeacon failed, so async flush should have been triggered
273+ await Promise . resolve ( ) ;
274+ expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
275+
276+ queue . stop ( ) ;
277+ } ) ;
278+
279+ it ( 'falls back to async flush if sendBeacon is unavailable' , async ( ) => {
280+ Object . defineProperty ( navigator , 'sendBeacon' , {
281+ value : undefined ,
282+ writable : true ,
283+ configurable : true ,
284+ } ) ;
285+
286+ const send = jest . fn ( ) . mockResolvedValue ( true ) ;
287+ const queue = createQueue ( { send } ) ;
288+ queue . start ( ) ;
289+
290+ queue . enqueue ( makeMessage ( '1' ) ) ;
291+ window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
292+
293+ await Promise . resolve ( ) ;
294+ expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
295+
296+ queue . stop ( ) ;
297+ } ) ;
298+
299+ it ( 'skips beacon if an async flush is already in flight' , async ( ) => {
300+ let resolveFlush : ( ) => void ;
301+ const flushPromise = new Promise < boolean > ( ( r ) => { resolveFlush = ( ) => r ( true ) ; } ) ;
302+ const send = jest . fn ( ) . mockReturnValueOnce ( flushPromise ) ;
303+
304+ const queue = createQueue ( { send } ) ;
305+ queue . start ( ) ;
306+ queue . enqueue ( makeMessage ( '1' ) ) ;
307+
308+ // Start an async flush (sets flushing = true)
309+ const pending = queue . flush ( ) ;
310+
311+ // pagehide fires while async flush is in flight — beacon should be skipped
312+ window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
313+ expect ( sendBeaconSpy ) . not . toHaveBeenCalled ( ) ;
314+
315+ resolveFlush ! ( ) ;
316+ await pending ;
317+ expect ( queue . length ) . toBe ( 0 ) ;
318+
319+ queue . stop ( ) ;
320+ } ) ;
321+ } ) ;
0 commit comments