What I Learned While Debugging a Random UI Freeze in a Jira Plugin
com.atlassian.confluence.content.render.xhtml.migration.exceptions.UnknownMacroMigrationException: The macro 'html' is unknown.

What I Learned While Debugging a Random UI Freeze in a Jira Plugin

Debugging a Ghost Click: When Custom Event Handling Broke a DataTable-Powered Jira Plugin

I spent almost two days investigating a random UI freeze on a Jira Data Center admin screen. The page had 600+ labels, multiple table operations, and an interface that looked perfectly alive — but suddenly stopped responding to clicks.

The bug report was vague:

“The screen randomly freezes after some actions. Only a hard reload fixes it.”

There were no console errors, failed API calls, stack traces, or crash dialogs. Rename stopped opening the input field. Delete stopped showing the confirmation dialog. Migrate, Refresh, and Sync with Jira also stopped responding.

The confusing part was that the page still looked healthy. The table rendered correctly. Pagination worked. Search was visible. Nothing appeared broken.

It just stopped listening.

This bug taught me more about frontend debugging than any straightforward error ever has — and most of the lesson came from understanding how custom event handling can silently break when a third-party library redraws the DOM underneath it.

What Labele DC Does

Labele DC is a Jira Data Center plugin for managing labels globally across projects. Its Global Settings screen is available under Jira Administration → Manage Apps → Manage Labels.

From there, admins can rename, delete, migrate or merge labels, refresh issue counts, and sync plugin label data with Jira’s native label system.

The screen runs inside a server-side jQuery DataTable and, during debugging, displayed 611 labels across 62 pages.

RandomFreeze.png
While renaming a label, the DataTable remained in a loading state and the UI stopped responding. Other label actions also became unavailable until the page was hard reloaded.

Finding the Pattern

At first, the bug looked random. No fixed steps, label, or action triggered it consistently. After logging screen loads and action sequences, the pattern became clear. The issue was not tied to one action; it was tied to repeated DataTable reloads.

Every major operation called the same reload method after completion:

dt.ajax.reload(null, false)

In a server-side jQuery DataTable, this redraws the table body with fresh data instead of updating one row. Each Rename, Delete, Migrate, Refresh, or Sync replaced the visible rows with new DOM elements. The table still looked correct, but some custom JavaScript held references to rows DataTables had already destroyed. The click was firing, but the code behind the click was no longer connected to the row currently visible to the user.

reload.png

Hard Reload as a Debugging Clue

Before jumping into the code, I tested what actually fixed the freeze.

A normal refresh helped sometimes, but not reliably. A hard reload using Ctrl + Shift + R fixed the screen every time. Incognito mode and disabling cache in Chrome DevTools also started the screen in a clean working state.

But rebuilding frontend assets did not change anything.

Debugging clue: The issue was not in compiled files, server responses, or cached assets. It was in runtime JavaScript state — old closures, stale variables, event bindings, or DOM references accumulating during the session.

Chrome DevTools Debugging Flow

I used Chrome DevTools step by step to rule out each possible cause: missing DOM, broken click handlers, failed API calls, and browser performance issues. Here is the list below

  1. Elements Tab

browser01.png
Elements tab check: The label rows and action icons were still present in the DOM, so the issue was not caused by missing HTML or hidden UI elements.
  1. Console Tab

browser02.png
Console tab check: The click handlers were still firing, which confirmed that JavaScript was receiving the user interaction even though the visible UI was not updating.

3. Network Tab

browser03.png
Network tab check: API calls were returning 200 OK, so the issue was not caused by backend failure or invalid server responses.

4. Performance Tab

browser05.png
Performance tab check: The browser was not truly frozen. The main thread was active, confirming that the issue was a frontend state mismatch, not a performance crash.

 

In the Performance tab, the browser was not truly frozen. The main thread was active, which confirmed this was a frontend state mismatch, not a performance crash.

To find the exact sequence, I added diagnostic logging around DataTable redraws, DOM row references, event bindings, selection state, and action locks.

Image022.png

Root Cause : Custom Event Handling Was Using Old Table State

The root cause was not that DataTables was broken.

DataTables was doing its job correctly. It was reloading data, redrawing rows, and managing the table state. The real problem was in the custom event handling written around the table. The custom code was still depending on old DOM references even after DataTables had already redrawn the table.

What was happening inside DataTables

Every major action, like Rename, Delete, Migrate, Refresh, or Sync, eventually called:

dt.ajax.reload(null, false)

In a server-side DataTable, this reloads fresh data and redraws the table body.

So the old table rows were removed, and new rows were created. To the user, the table looked the same. But technically, the old <tr> elements were gone and replaced with new ones.

Where custom event handling caused the problem

The custom handlers were still reading row information from the DOM using code like:

$(this).closest("tr")

In some flows, row data was also stored on temporary DOM elements:

$titleWrap.data("rowData")

This worked only while that exact row existed on the page.

After DataTables redrew the table, those old row references could become stale. So when the user clicked Rename, Delete, Migrate, or Refresh, the click event still fired, but the code could be working with an old row reference instead of the row currently visible on screen.

That is why it felt like a “ghost click.”

The user clicked.
The handler ran.
But the UI did not respond correctly.

Detatched.png

 

Why This Was Misleading

Custom event handling made the issue harder to spot because the handlers were not fully broken.

Clicks still reached the handlers, the code still ran, and there were no console errors. That made the event layer look healthy.

The real problem was not whether the handlers fired — it was which state they trusted after firing.

Where the State Went Stale

After DataTables redrew the table, some handlers still depended on old row references, temporary DOM data, or outdated selection state.

From outside, the action looked active. Internally, it was no longer connected to the row visible on screen.

Misleading Symptoms

  • Rename looked like a Rename-only bug because inline input and blur handling used temporary row references.

  • Outside-click handling became inconsistent because document handlers compared clicks against stale DOM elements.

  • Selection looked correct visually but DataTables could be tracking a different selected row internally.

  • Action locks felt like a freeze when a failed or interrupted flow did not release the lock.

That is why the investigation drifted at first:

The browser was not frozen.
The backend was not failing.
The click handlers were not dead.

The custom event layer was alive — but sometimes trusted stale assumptions about a table DataTables had already redrawn.

The Fix: Let DataTables Own the Table State

After the fix, the screen used DataTables as the single source of truth:

  • Use row().data() and row().invalidate() for targeted row updates

  • Use stable row IDs such as dt.row('#labelId') instead of long-lived DOM references

  • Use DataTables’ row data instead of storing state on transient DOM nodes

  • Let the DataTables select plugin own selection state

  • Re-resolve row data during each delegated click event

  • Release action locks in both success and error paths

What This Debugging Session Taught Me

Key lesson: UI can look healthy while its internal state is already broken. In this case, the browser was not frozen, the backend was not failing, and the click handlers were still firing. The real issue was that custom event handling was working with stale table state after DataTables had redrawn the DOM.

The best debugging path was to narrow the issue layer by layer: DOM, events, network, performance, DataTable redraws, and finally custom event state.

 

Final takeaway: when a library owns UI state, avoid duplicating that state through cached DOM references or temporary event data. In a DataTable-powered screen, DataTables should stay the source of truth, and custom handlers should always re-read the latest row state before acting.

References