fix(ui): prevent ng-event listener accumulation on iframe reload - #37113
fix(ui): prevent ng-event listener accumulation on iframe reload#37113gortiz-dotcms wants to merge 1 commit into
Conversation
) 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 <noreply@anthropic.com>
|
Claude finished @gortiz-dotcms's task in 53s —— View job Claude finished @gortiz-dotcms's task in 47s —— View job Code ReviewTask list
I reviewed both changed files against the branch. The fix is correct and minimal. AnalysisThe root cause is accurate: Binding once into
New IssuesNo issues found. Notes (non-blocking)
The fix is correct, conventions are respected, and the change is safe to roll back as labeled. · branch |
| this.getIframeWindow().document.removeEventListener('ng-event', this.boundEmitCustomEvent); | ||
|
|
||
| this.getIframeWindow().addEventListener('keydown', this.boundEmitKeyDown); | ||
| this.getIframeWindow().document.addEventListener('ng-event', this.boundEmitCustomEvent); |
There was a problem hiding this comment.
I traced the save path for this one and wanted to check the root cause with you. keydown is registered on getIframeWindow() (the WindowProxy, which persists across navigations, so it can genuinely accumulate), but ng-event is registered on getIframeWindow().document — and a navigation always creates a fresh Document. iframe-porlet-legacy.component.ts:182-190 goes further and destroys/recreates the whole <iframe> via the isLoading gate whenever the URL changes, so the document listener starts from zero each load.
Since the duplicate save runs through ng-event (RemotePublisherDialog.js:271-276 -> this listener -> DotCustomEventHandlerService -> openWizard), and DotWizardService.open() completes any pending subject without emitting before creating a new one, would two stacked listeners actually produce two saveContent calls here?
Have you been able to confirm in the Network tab that the two ContentletAjax.saveContent.dwr calls collapse to one? That's the unchecked item in your own test plan, and it would settle it. If it turns out the duplicate persists, would it be worth dropping the Fixes #34534 link so the issue isn't auto-closed?
| // 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. |
There was a problem hiding this comment.
Small wording question on this comment. The first half is exactly right — .bind(this) returns a new object each call, so removeEventListener never matched. But the last clause, "causing listeners to accumulate on every iframe load event," reads as if it applies to both fields below it.
As far as I can tell it only holds for keydown, which is attached to getIframeWindow() (the WindowProxy keeps its identity across navigations). ng-event is attached to getIframeWindow().document, and each navigation brings a fresh Document, so the old listener goes away with the old document rather than piling up.
Would it be worth splitting that out so the next person reading this doesn't assume both listeners leak the same way?
| }); | ||
|
|
||
| it('should remove and add listener on load', () => { | ||
| it('should remove and add listener on load using the same stable function references', () => { |
There was a problem hiding this comment.
Would it be worth adding a test that exercises the symptom directly? Right now the assertions stop at which references were handed to add/removeEventListener after a single load, so nothing covers the property that actually matters here: one dispatched ng-event producing exactly one emission after several reloads.
Something like triggering load two or three times, grabbing the handler from contentWindow.document.addEventListener.mock.calls, invoking it once, and asserting custom.emit fired once would pin the regression down. As it stands, a refactor could bring the duplicate back without turning the suite red.
| }); | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const keyDownRef = (comp as any).boundEmitKeyDown; |
There was a problem hiding this comment.
Would deriving these from the mock call history work just as well here? Something like:
const [, keyDownRef] = comp.iframeElement.nativeElement.contentWindow.addEventListener.mock.calls[0];That asserts the same invariant without depending on the private field names, so a later rename wouldn't break the test. It would also leave the door open to switching these to # private fields per TYPESCRIPT_STANDARDS, which the (comp as any) access would otherwise block.
Summary
Fixes #34534 — saving new content via a workflow action with Allow Comments enabled fails with a unique constraint error on URLTitle (and
identifier_pkeyat the DB level).IframeComponent.handleIframeEvents()called.bind(this)inline for bothaddEventListenerandremoveEventListener. Each.bind()call creates a new function object, soremoveEventListenernever matched the previously registered handler — silently accumulating an extrang-eventlistener on the iframe document on everyonLoadevent. With two listeners, theworkflow-wizardcustom event reached Angular twice, triggering two concurrentContentletAjax.saveContentDWR calls. Both calls saw an empty inode (new content) and raced toINSERTthe same deterministic identifier, the second losing with a unique constraint violation.boundEmitKeyDown,boundEmitCustomEvent) so the same stable object is passed to bothaddEventListenerandremoveEventListener, ensuring eachonLoadcorrectly replaces the listener instead of stacking a new one.Test plan
pnpm nx test dotcms-ui --testPathPattern=iframe.component.specpassesContentletAjax.saveContentcall appears in the Network tab (previously two)🤖 Generated with Claude Code