Skip to content

fix(ui): prevent ng-event listener accumulation on iframe reload - #37113

Open
gortiz-dotcms wants to merge 1 commit into
mainfrom
issue-34534-iframe-listener-fix
Open

fix(ui): prevent ng-event listener accumulation on iframe reload#37113
gortiz-dotcms wants to merge 1 commit into
mainfrom
issue-34534-iframe-listener-fix

Conversation

@gortiz-dotcms

Copy link
Copy Markdown
Member

Summary

Fixes #34534 — saving new content via a workflow action with Allow Comments enabled fails with a unique constraint error on URLTitle (and identifier_pkey at the DB level).

  • Root cause: IframeComponent.handleIframeEvents() called .bind(this) inline for both addEventListener and removeEventListener. Each .bind() call creates a new function object, so removeEventListener never matched the previously registered handler — silently accumulating an extra ng-event listener on the iframe document on every onLoad event. With two listeners, the workflow-wizard custom event reached Angular twice, triggering two concurrent ContentletAjax.saveContent DWR calls. Both calls saw an empty inode (new content) and raced to INSERT the same deterministic identifier, the second losing with a unique constraint violation.
  • Fix: Bind the handler references once as class fields (boundEmitKeyDown, boundEmitCustomEvent) so the same stable object is passed to both addEventListener and removeEventListener, ensuring each onLoad correctly replaces the listener instead of stacking a new one.
  • Test updated: The existing spec now asserts the same function reference is used for both add and remove, which is the invariant the fix enforces.

Test plan

  • pnpm nx test dotcms-ui --testPathPattern=iframe.component.spec passes
  • Open the content editor for a new contentlet
  • Use a workflow action with Allow Comments enabled, enter a comment, and submit
  • Confirm only one ContentletAjax.saveContent call appears in the Network tab (previously two)
  • Confirm the content saves without a unique constraint error

🤖 Generated with Claude Code

)

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

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @gortiz-dotcms's task in 53s —— View job


Claude finished @gortiz-dotcms's task in 47s —— View job


Code Review

Task list

  • Read changed files (iframe.component.ts, iframe.component.spec.ts)
  • Analyze root cause & fix correctness
  • Check for bugs, conventions, test coverage
  • Post review

I reviewed both changed files against the branch. The fix is correct and minimal.

Analysis

The root cause is accurate: addEventListener/removeEventListener compare handlers by object identity, and each inline .bind(this) produces a fresh function object, so the removeEventListener calls in handleIframeEvents() were silent no-ops. Every onLoad stacked another ng-event listener, which is what caused the duplicate saveContent DWR call and the resulting unique-constraint race.

Binding once into readonly class fields (boundEmitKeyDown, boundEmitCustomEvent) and reusing those same references for both add and remove is the standard fix and resolves the accumulation.

  • Field-initializer binding is safe here — emitKeyDown/emitCustonEvent are prototype methods, so they exist when the fields initialize (iframe.component.ts:83-84).
  • The updated spec asserts the real invariant: the same reference is passed to both addEventListener and removeEventListener for keydown and ng-event (iframe.component.spec.ts:238-250). 👍

New Issues

No issues found.

Notes (non-blocking)

  • 🟡 Minor: The spec verifies the same-reference invariant on a single load, but not the actual accumulation scenario. A test that fires load twice and asserts the ng-event listener count stays at 1 (or that removeEventListener is invoked before each re-add) would guard the regression more directly. Not required to merge.
  • The pre-existing typo emitCustonEvent is untouched by this PR and out of scope.

The fix is correct, conventions are respected, and the change is safe to roll back as labeled.

· branch issue-34534-iframe-listener-fix

this.getIframeWindow().document.removeEventListener('ng-event', this.boundEmitCustomEvent);

this.getIframeWindow().addEventListener('keydown', this.boundEmitKeyDown);
this.getIframeWindow().document.addEventListener('ng-event', this.boundEmitCustomEvent);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Allow comments on workflow action is throwing error while saving content

2 participants