Coalesce redundant per-frame button updates on bursty events (shapeshift/form swaps) to reduce stalls #32


Open
  • prof1x created this issue Aug 8, 2026

    Client: WoW Classic — Mists of Pandaria (5.5.x). Likely relevant to any client where certain events fire in bursts within a single frame.

    Problem

    On some events, the client fires many redundant events within a single frame, and LibActionButton currently reacts to each one by iterating the button set and running work immediately. The worst offender in my case is toggling a form/shapeshift (Warlock Metamorphosis), which within one frame produces:

    • ~8× ACTIONBAR_SLOT_CHANGED
    • ~11× SPELL_UPDATE_ICON — each currently runs ForAllButtons(Update, true), i.e. 11 full updates of every active button in one frame
    • multiple ACTIONBAR_UPDATE_STATE / ACTIONBAR_UPDATE_USABLE / ACTIONBAR_UPDATE_COOLDOWN / SPELL_UPDATE_USABLE / SPELL_UPDATE_COOLDOWN
    • SPELLS_CHANGED (flyout rescan)

    Update() is the heaviest function in the lib (icon/glow/cooldown, secure Execute for OnStateChanged, and the OnButtonUpdate callback that Masque and glow addons listen to), so running it 11× per frame, plus several more full sweeps for the cooldown/usable/state events, produces a visible hitch on every form toggle. scriptProfile attributed ~20 ms of Lua to the lib during the toggle on top of engine-side work.

    Proposed change

    Coalesce these bursty events: instead of doing the work inline in OnEvent, set a flag and perform the actual update once in the next OnUpdate tick. A one-frame delay is visually imperceptible, and 11 full sweeps collapse into 1. This is a drop-in change — no API or behavior change, buttons end up in the same state, just updated once per frame instead of N times.

    Concretely:

    • SPELL_UPDATE_ICON → instead of ForAllButtons(Update, true), set an iconUpdatePending flag; in OnUpdate, only refresh icon textures (button.icon:SetTexture(button:GetTexture())) for active buttons — the state/usable/cooldown events in the same burst already cover the rest, so a full Update is unnecessary here.
    • ACTIONBAR_SLOT_CHANGED → accumulate changed slots into a dirtySlots table (or a fullUpdatePending flag when arg1 == 0) and process once in OnUpdate.
    • ACTIONBAR_UPDATE_STATE / ACTIONBAR_UPDATE_USABLE / ACTIONBAR_UPDATE_COOLDOWN / SPELL_UPDATE_USABLE / SPELL_UPDATE_COOLDOWN / SPELL_UPDATE_CHARGES → mark a pendingLight[event] flag; run each affected sweep at most once per frame in OnUpdate.
    • SPELLS_CHANGED / SPELL_FLYOUT_UPDATE → defer UpdateFlyoutSpells() and throttle it to at most once per second.

    If a full update runs in a given frame, the lighter pending flags for that frame are cleared, since the full Update already covers state/usable/cooldown/count/icon.

    Results (Demonology Warlock, Metamorphosis toggle)

    Combined with an unrelated Bartender4 fix (the button.bar = nil stall, now fixed upstream), the per-toggle freeze went from ~0.6–0.7 s down to a barely-perceptible dip. The LAB portion of the work dropped from 11 full button sweeps to 1 per frame. No visual regressions observed: icons swap correctly on form change, mana/range desaturation, cooldown swipes, and charge counts all update as before.

    Notes

    • I've been running this patched locally for a while with no issues on MoP Classic.
    • I can provide a full diff / PR against the current code if that's easier to review — happy to open one if you're open to the change.

To post a comment, please login or register a new account.