@@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
22import { JSDOM } from 'jsdom' ;
33import record from '../../src/record' ;
44import { mutationBuffers } from '../../src/record/observer' ;
5+ import { IframeManager } from '../../src/record/iframe-manager' ;
56import type { eventWithTime } from '@posthog/rrweb-types' ;
7+ import { createMirror } from '@posthog/rrweb-snapshot' ;
68
79describe ( 'memory leak prevention' , ( ) => {
810 let dom : JSDOM ;
@@ -255,4 +257,258 @@ describe('memory leak prevention', () => {
255257 global . WeakMap = originalWeakMap ;
256258 } ) ;
257259 } ) ;
260+
261+ describe ( 'IframeManager.removeIframeById cleanup' , ( ) => {
262+ it ( 'should delete iframe from iframes WeakMap when removeIframeById is called' , async ( ) => {
263+ const emit = ( event : eventWithTime ) => {
264+ events . push ( event ) ;
265+ } ;
266+
267+ const stopRecording = record ( {
268+ emit,
269+ recordCrossOriginIframes : true ,
270+ } ) ;
271+
272+ // Create and append an iframe
273+ const iframe = document . createElement ( 'iframe' ) ;
274+ document . body . appendChild ( iframe ) ;
275+
276+ // Wait for mutations to be processed
277+ await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
278+
279+ // Remove the iframe - this should trigger removeIframeById via wrappedMutationEmit
280+ document . body . removeChild ( iframe ) ;
281+
282+ // Wait for removal mutation to be processed
283+ await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
284+
285+ // Verify that a removal mutation was emitted
286+ const removalEvents = events . filter (
287+ ( e : any ) =>
288+ e . type === 3 && // IncrementalSnapshot
289+ e . data ?. source === 0 && // Mutation
290+ e . data ?. removes ?. length > 0 ,
291+ ) ;
292+ expect ( removalEvents . length ) . toBeGreaterThan ( 0 ) ;
293+
294+ stopRecording ?.( ) ;
295+ } ) ;
296+
297+ it ( 'should delete contentWindow from crossOriginIframeMap when removeIframeById is called' , async ( ) => {
298+ const emit = ( event : eventWithTime ) => {
299+ events . push ( event ) ;
300+ } ;
301+
302+ const stopRecording = record ( {
303+ emit,
304+ recordCrossOriginIframes : true ,
305+ } ) ;
306+
307+ // Create and append an iframe
308+ const iframe = document . createElement ( 'iframe' ) ;
309+ document . body . appendChild ( iframe ) ;
310+
311+ // Wait for mutations to be processed
312+ await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
313+
314+ // Store a reference to verify cleanup later
315+ const contentWindow = iframe . contentWindow ;
316+ expect ( contentWindow ) . not . toBeNull ( ) ;
317+
318+ // Remove the iframe - this triggers removeIframeById
319+ document . body . removeChild ( iframe ) ;
320+
321+ // Wait for removal mutation to be processed
322+ await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
323+
324+ // The crossOriginIframeMap.delete(win) should have been called
325+ // We can verify this indirectly by checking the removal mutation was processed
326+ const removalEvents = events . filter (
327+ ( e : any ) =>
328+ e . type === 3 && // IncrementalSnapshot
329+ e . data ?. source === 0 && // Mutation
330+ e . data ?. removes ?. length > 0 ,
331+ ) ;
332+ expect ( removalEvents . length ) . toBeGreaterThan ( 0 ) ;
333+
334+ stopRecording ?.( ) ;
335+ } ) ;
336+
337+ it ( 'should NOT call removeIframeById when iframe is moved (appears in both removes and adds)' , async ( ) => {
338+ const emit = ( event : eventWithTime ) => {
339+ events . push ( event ) ;
340+ } ;
341+
342+ const stopRecording = record ( {
343+ emit,
344+ recordCrossOriginIframes : true ,
345+ } ) ;
346+
347+ // Create a container and an iframe
348+ const container1 = document . createElement ( 'div' ) ;
349+ const container2 = document . createElement ( 'div' ) ;
350+ document . body . appendChild ( container1 ) ;
351+ document . body . appendChild ( container2 ) ;
352+
353+ const iframe = document . createElement ( 'iframe' ) ;
354+ container1 . appendChild ( iframe ) ;
355+
356+ // Wait for mutations to be processed
357+ await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
358+
359+ const initialEventCount = events . length ;
360+
361+ // Move the iframe from container1 to container2
362+ // This will trigger a mutation with the iframe in BOTH removes and adds
363+ container2 . appendChild ( iframe ) ;
364+
365+ // Wait for move mutation to be processed
366+ await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
367+
368+ // Verify that a mutation was emitted
369+ const mutationEvents = events . slice ( initialEventCount ) . filter (
370+ ( e : any ) =>
371+ e . type === 3 && // IncrementalSnapshot
372+ e . data ?. source === 0 , // Mutation
373+ ) ;
374+ expect ( mutationEvents . length ) . toBeGreaterThan ( 0 ) ;
375+
376+ // The iframe should still be tracked (not cleaned up)
377+ // We can verify this by removing it and seeing a removal event
378+ iframe . remove ( ) ;
379+ await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
380+
381+ const removalEvents = events . filter (
382+ ( e : any ) =>
383+ e . type === 3 && // IncrementalSnapshot
384+ e . data ?. source === 0 && // Mutation
385+ e . data ?. removes ?. length > 0 ,
386+ ) ;
387+ expect ( removalEvents . length ) . toBeGreaterThan ( 0 ) ;
388+
389+ stopRecording ?.( ) ;
390+ } ) ;
391+ } ) ;
392+
393+ describe ( 'IframeManager unit tests' , ( ) => {
394+ it . each ( [
395+ {
396+ scenario : 'with contentWindow' ,
397+ shouldAppendToDOM : true ,
398+ shouldCallRemove : true ,
399+ expectedMapsCleanedUp : true ,
400+ } ,
401+ {
402+ scenario : 'without contentWindow' ,
403+ shouldAppendToDOM : false ,
404+ shouldCallRemove : true ,
405+ expectedMapsCleanedUp : true ,
406+ } ,
407+ {
408+ scenario : 'when iframe is moved (not removed)' ,
409+ shouldAppendToDOM : true ,
410+ shouldCallRemove : false ,
411+ expectedMapsCleanedUp : false ,
412+ } ,
413+ ] ) (
414+ 'should handle cleanup correctly $scenario' ,
415+ ( { shouldAppendToDOM, shouldCallRemove, expectedMapsCleanedUp } ) => {
416+ const mirror = createMirror ( ) ;
417+ const mutationCb = vi . fn ( ) ;
418+ const wrappedEmit = vi . fn ( ) ;
419+
420+ const mockStylesheetManager = {
421+ styleMirror : {
422+ generateId : vi . fn ( ( ) => 1 ) ,
423+ } ,
424+ adoptStyleSheets : vi . fn ( ) ,
425+ } as any ;
426+
427+ const iframeManager = new IframeManager ( {
428+ mirror,
429+ mutationCb,
430+ stylesheetManager : mockStylesheetManager ,
431+ recordCrossOriginIframes : true ,
432+ wrappedEmit,
433+ } ) ;
434+
435+ const iframe = document . createElement ( 'iframe' ) ;
436+ if ( shouldAppendToDOM ) {
437+ document . body . appendChild ( iframe ) ;
438+ }
439+
440+ const iframeId = 123 ;
441+ mirror . add ( iframe , {
442+ type : 2 ,
443+ tagName : 'iframe' ,
444+ attributes : { } ,
445+ childNodes : [ ] ,
446+ id : iframeId ,
447+ } ) ;
448+
449+ if ( shouldAppendToDOM ) {
450+ iframeManager . addIframe ( iframe ) ;
451+
452+ const mockChildSn = {
453+ type : 0 ,
454+ childNodes : [ ] ,
455+ id : 456 ,
456+ } as any ;
457+
458+ iframeManager . attachIframe ( iframe , mockChildSn ) ;
459+ expect ( mutationCb ) . toHaveBeenCalled ( ) ;
460+ } else {
461+ // Manually set up attachedIframes for non-DOM case
462+ const manager = iframeManager as any ;
463+ manager . attachedIframes . set ( iframeId , {
464+ element : iframe ,
465+ content : { type : 0 , childNodes : [ ] , id : 999 } ,
466+ } ) ;
467+ }
468+
469+ const manager = iframeManager as any ;
470+
471+ // Verify initial state
472+ if ( shouldAppendToDOM ) {
473+ expect ( manager . iframes . has ( iframe ) ) . toBe ( true ) ;
474+ if ( iframe . contentWindow ) {
475+ expect ( manager . crossOriginIframeMap . has ( iframe . contentWindow ) ) . toBe (
476+ true ,
477+ ) ;
478+ }
479+ }
480+ expect ( manager . attachedIframes . has ( iframeId ) ) . toBe ( true ) ;
481+
482+ // Perform action
483+ if ( shouldCallRemove ) {
484+ expect ( ( ) => iframeManager . removeIframeById ( iframeId ) ) . not . toThrow ( ) ;
485+ }
486+
487+ // Verify final state
488+ if ( expectedMapsCleanedUp ) {
489+ expect ( manager . iframes . has ( iframe ) ) . toBe ( false ) ;
490+ if ( iframe . contentWindow ) {
491+ expect ( manager . crossOriginIframeMap . has ( iframe . contentWindow ) ) . toBe (
492+ false ,
493+ ) ;
494+ }
495+ expect ( manager . attachedIframes . has ( iframeId ) ) . toBe ( false ) ;
496+ } else {
497+ // For moved iframes, maps should remain intact
498+ expect ( manager . iframes . has ( iframe ) ) . toBe ( true ) ;
499+ if ( iframe . contentWindow ) {
500+ expect ( manager . crossOriginIframeMap . has ( iframe . contentWindow ) ) . toBe (
501+ true ,
502+ ) ;
503+ }
504+ }
505+
506+ // Clean up
507+ if ( shouldAppendToDOM ) {
508+ document . body . removeChild ( iframe ) ;
509+ }
510+ iframeManager . destroy ( ) ;
511+ } ,
512+ ) ;
513+ } ) ;
258514} ) ;
0 commit comments