From d0f676a20dd805b773dc99476957ce02ecdc1aa2 Mon Sep 17 00:00:00 2001 From: gortiz-dotcms Date: Wed, 19 Aug 2026 15:55:34 -0300 Subject: [PATCH] fix(ui): prevent ng-event listener accumulation on iframe reload (#34534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each .bind(this) call produces a new function object; removeEventListener silently failed because the reference never matched, stacking an extra ng-event listener on the iframe document every time onLoad fired. With two listeners the workflow-wizard event reached Angular twice, causing saveAssignCallBackAngular to fire two concurrent DWR saves for new content and racing to INSERT the same identifier — yielding a unique constraint violation on URLTitle / identifier_pkey. Store the bound handler references as class fields so the same object is passed to both addEventListener and removeEventListener, ensuring each onLoad cleanly replaces the listener instead of appending a new one. Refs: #34534 Co-Authored-By: Claude Sonnet 4.6 --- .../iframe-component/iframe.component.spec.ts | 17 ++++++++++---- .../iframe-component/iframe.component.ts | 23 ++++++++++--------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts index a46945552a3f..9cdbac5f8326 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts @@ -219,7 +219,7 @@ describe('IframeComponent', () => { }; }); - it('should remove and add listener on load', () => { + it('should remove and add listener on load using the same stable function references', () => { iframeEl.triggerEventHandler('load', { target: { contentDocument: { @@ -228,19 +228,26 @@ describe('IframeComponent', () => { } }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const keyDownRef = (comp as any).boundEmitKeyDown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const customEventRef = (comp as any).boundEmitCustomEvent; + + // remove must receive the exact same reference as add so the browser + // can actually deregister the handler instead of silently failing expect( comp.iframeElement.nativeElement.contentWindow.removeEventListener - ).toHaveBeenCalledWith('keydown', expect.any(Function)); + ).toHaveBeenCalledWith('keydown', keyDownRef); expect( comp.iframeElement.nativeElement.contentWindow.document.removeEventListener - ).toHaveBeenCalledWith('ng-event', expect.any(Function)); + ).toHaveBeenCalledWith('ng-event', customEventRef); expect( comp.iframeElement.nativeElement.contentWindow.addEventListener - ).toHaveBeenCalledWith('keydown', expect.any(Function)); + ).toHaveBeenCalledWith('keydown', keyDownRef); expect( comp.iframeElement.nativeElement.contentWindow.document.addEventListener - ).toHaveBeenCalledWith('ng-event', expect.any(Function)); + ).toHaveBeenCalledWith('ng-event', customEventRef); }); it('should set the colors to the jsp on load', () => { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts index 61c68f3a7ac9..06aeb5621c88 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts @@ -76,6 +76,13 @@ export class IframeComponent implements OnInit, OnDestroy { private destroy$: Subject = new Subject(); + // Stable bound references required so removeEventListener can match the + // exact function object that was passed to addEventListener. Using + // .bind(this) inline creates a new object each call, making removal a + // no-op and causing listeners to accumulate on every iframe load event. + private readonly boundEmitKeyDown = this.emitKeyDown.bind(this); + private readonly boundEmitCustomEvent = this.emitCustonEvent.bind(this); + ngOnInit(): void { this.iframeOverlayService.overlay .pipe(takeUntil(this.destroy$)) @@ -264,17 +271,11 @@ export class IframeComponent implements OnInit, OnDestroy { } private handleIframeEvents($event): void { - this.getIframeWindow().removeEventListener('keydown', this.emitKeyDown.bind(this)); - this.getIframeWindow().document.removeEventListener( - 'ng-event', - this.emitCustonEvent.bind(this) - ); - - this.getIframeWindow().addEventListener('keydown', this.emitKeyDown.bind(this)); - this.getIframeWindow().document.addEventListener( - 'ng-event', - this.emitCustonEvent.bind(this) - ); + this.getIframeWindow().removeEventListener('keydown', this.boundEmitKeyDown); + this.getIframeWindow().document.removeEventListener('ng-event', this.boundEmitCustomEvent); + + this.getIframeWindow().addEventListener('keydown', this.boundEmitKeyDown); + this.getIframeWindow().document.addEventListener('ng-event', this.boundEmitCustomEvent); this.charge.emit($event); const doc = this.getIframeDocument();