Skip to content

perf: Pre-register event listeners to avoid DOM scanning#55

Merged
MCGPPeters merged 3 commits intomainfrom
perf/event-delegation
Feb 9, 2026
Merged

perf: Pre-register event listeners to avoid DOM scanning#55
MCGPPeters merged 3 commits intomainfrom
perf/event-delegation

Conversation

@MCGPPeters
Copy link
Copy Markdown
Contributor

📝 Description

What

Pre-register all common event types at module load time to eliminate O(n) DOM scanning on every update.

Why

The addEventListeners() function was using querySelectorAll('*') to scan all DOM nodes for data-event-* attributes on every incremental update (AddChild, ReplaceChild). This is O(n) for all nodes in the document.

How

  1. Pre-register 60+ common event types at module load time (COMMON_EVENT_TYPES.forEach(ensureEventListener))
  2. Skip scanning for incremental updates - when root is a subtree, return early since common events are already registered
  3. Only scan on full page render - for initial load or to discover rare custom events
  4. Use TreeWalker instead of querySelectorAll when scanning is needed (more memory efficient)

🔗 Related Issues

Part of the performance optimization plan from #50

✅ Type of Change

  • ⚡ Performance improvement

🧪 Testing

Test Coverage

  • Unit tests pass (85/85)
  • E2E tests pass (ArticleTests, AuthenticationTests)
  • Benchmark run (js-framework-benchmark)

Testing Details

  • Benchmark: 397.4ms mean for swap_rows (vs 388.3ms baseline - within margin of error)
  • The optimization primarily benefits real-world apps with frequent incremental updates
  • All existing tests continue to pass

✨ Changes Made

  • Added COMMON_EVENT_TYPES array with 60+ event types matching Operations.cs
  • Pre-register all common events at startup via COMMON_EVENT_TYPES.forEach(ensureEventListener)
  • Modified addEventListeners(root) to skip scanning for incremental updates
  • Changed full-page scan to use TreeWalker instead of querySelectorAll

🔍 Code Review Checklist

  • Code follows the project's style guidelines
  • Self-review of code performed
  • Code changes generate no new warnings
  • Tests added/updated and passing

- Pre-register 60+ common event types at module load time
- Skip O(n) querySelectorAll scanning for incremental updates
- Only scan on full page render for custom event types
- Use TreeWalker instead of querySelectorAll when scanning

This eliminates the addEventListeners() overhead on every DOM update,
which was scanning all nodes to discover data-event-* attributes.
Now that all common events are pre-registered, incremental updates
(AddChild, ReplaceChild) skip scanning entirely.
Copilot AI review requested due to automatic review settings February 9, 2026 10:40
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR aims to improve Abies’ browser-side event handling performance by avoiding repeated DOM scans for data-event-* attributes, shifting work to module startup and using a TreeWalker for the remaining scan path.

Changes:

  • Added a COMMON_EVENT_TYPES list and pre-registered those listeners at module load time.
  • Changed addEventListeners(root) to skip scanning for subtree updates and to use TreeWalker when scanning.
  • Applied the same abies.js changes to consuming app copies under wwwroot/.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
Abies/wwwroot/abies.js Adds common-event pre-registration and modifies event discovery logic to reduce DOM scanning work.
Abies.SubscriptionsDemo/wwwroot/abies.js Updates a consuming app’s local abies.js copy to match the canonical runtime changes.
Abies.Presentation/wwwroot/abies.js Updates a consuming app’s local abies.js copy to match the canonical runtime changes.

Comment on lines +454 to +481
// Pre-register all common event types at startup to avoid O(n) DOM scanning
// on every incremental update. These match the event types defined in Operations.cs.
const COMMON_EVENT_TYPES = [
// Mouse events
'click', 'dblclick', 'mousedown', 'mouseup', 'mouseover', 'mouseout',
'mouseenter', 'mouseleave', 'mousemove', 'contextmenu', 'wheel',
// Keyboard events
'keydown', 'keyup', 'keypress',
// Form events
'input', 'change', 'submit', 'reset', 'focus', 'blur', 'invalid', 'search',
// Touch events
'touchstart', 'touchend', 'touchmove', 'touchcancel',
// Pointer events
'pointerdown', 'pointerup', 'pointermove', 'pointercancel',
'pointerover', 'pointerout', 'pointerenter', 'pointerleave',
'gotpointercapture', 'lostpointercapture',
// Drag events
'drag', 'dragstart', 'dragend', 'dragenter', 'dragleave', 'dragover', 'drop',
// Clipboard events
'copy', 'cut', 'paste',
// Media events
'play', 'pause', 'ended', 'volumechange', 'timeupdate', 'seeking', 'seeked',
'loadeddata', 'loadedmetadata', 'canplay', 'canplaythrough', 'playing',
'waiting', 'stalled', 'suspend', 'emptied', 'ratechange', 'durationchange',
// Other events
'scroll', 'resize', 'load', 'error', 'abort', 'select', 'toggle',
'animationstart', 'animationend', 'animationiteration', 'animationcancel',
'transitionstart', 'transitionend', 'transitionrun', 'transitioncancel'
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

COMMON_EVENT_TYPES pre-registers high-frequency events like mousemove, pointermove, scroll, and wheel. Because genericEventHandler always calls event.composedPath() and walks the path even when no data-event-* attribute exists, this adds steady-state overhead for every one of those events in every app (even if the app never uses them). Consider limiting pre-registration to a small set of low-frequency “always used” events (e.g., click/input/change/submit/keydown/keyup) and keep lazy registration (via attribute patches or subtree scan) for the rest.

Copilot uses AI. Check for mistakes.
Comment on lines +492 to +494
// Pre-register all common event types once at module load
// This eliminates the need for O(n) DOM scanning on every update
COMMON_EVENT_TYPES.forEach(ensureEventListener);
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

COMMON_EVENT_TYPES.forEach(ensureEventListener) runs before exports is initialized (const exports = await getAssemblyExports(...) later in the module). If a user interacts with an element that already has a data-event-* attribute during startup, genericEventHandler will run and can throw a ReferenceError due to exports being in the TDZ. To avoid startup crashes, delay registering these listeners until after exports is assigned (and ideally after runMain()), or change exports to a let initialized to null up-front and guard in genericEventHandler when exports/runtime isn’t ready yet.

Suggested change
// Pre-register all common event types once at module load
// This eliminates the need for O(n) DOM scanning on every update
COMMON_EVENT_TYPES.forEach(ensureEventListener);
// Pre-register all common event types after initial document load instead of
// during module evaluation. This avoids invoking genericEventHandler while
// the runtime exports may still be in the temporal dead zone.
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', () => {
COMMON_EVENT_TYPES.forEach(ensureEventListener);
}, { once: true });
} else {
COMMON_EVENT_TYPES.forEach(ensureEventListener);
}

Copilot uses AI. Check for mistakes.
Comment on lines +869 to +879
// Skip scanning entirely during incremental updates since:
// 1. Common events are already registered
// 2. Attribute patches handle event listener registration
if (root && root !== document) {
// For incremental updates (AddChild, ReplaceChild), skip expensive DOM scanning
// The pre-registered common events cover 99%+ of use cases
return;
}

// Full page render (setAppContent) - scan once for any custom event types
// This only happens on initial load or full page navigation
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

addEventListeners(root) now returns early for any subtree root (root && root !== document). However, AddChild/ReplaceChild patches insert raw HTML fragments and then call addEventListeners(childElement/newNode) specifically to discover data-event-* attributes in the inserted subtree. With the early return, any non-pre-registered (custom/rare) event types in newly added HTML will never be registered, breaking event dispatch. Either keep scanning for subtree roots (TreeWalker already makes it cheaper), or ensure the AddChild/ReplaceChild patch path registers event types without relying on a DOM scan.

Suggested change
// Skip scanning entirely during incremental updates since:
// 1. Common events are already registered
// 2. Attribute patches handle event listener registration
if (root && root !== document) {
// For incremental updates (AddChild, ReplaceChild), skip expensive DOM scanning
// The pre-registered common events cover 99%+ of use cases
return;
}
// Full page render (setAppContent) - scan once for any custom event types
// This only happens on initial load or full page navigation
// Full page render (setAppContent) or subtree scan (AddChild/ReplaceChild) -
// scan for any custom event types in the given scope.

Copilot uses AI. Check for mistakes.
Comment on lines +454 to +495
@@ -459,6 +489,10 @@ function ensureEventListener(eventName) {
registeredEvents.add(eventName);
}

// Pre-register all common event types once at module load
// This eliminates the need for O(n) DOM scanning on every update
COMMON_EVENT_TYPES.forEach(ensureEventListener);

Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

This file is a generated copy of the canonical Abies/wwwroot/abies.js (it is overwritten by the SyncAbiesJs MSBuild target). Editing it directly will be lost on build/publish and creates divergence risk. Please remove changes from this copied file and only update Abies/wwwroot/abies.js.

Copilot uses AI. Check for mistakes.
Comment on lines +454 to +495
@@ -459,6 +489,10 @@ function ensureEventListener(eventName) {
registeredEvents.add(eventName);
}

// Pre-register all common event types once at module load
// This eliminates the need for O(n) DOM scanning on every update
COMMON_EVENT_TYPES.forEach(ensureEventListener);

Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

This file is a generated copy of the canonical Abies/wwwroot/abies.js (it is overwritten by the SyncAbiesJs MSBuild target). Editing it directly will be lost on build/publish and creates divergence risk. Please remove changes from this copied file and only update Abies/wwwroot/abies.js.

Copilot uses AI. Check for mistakes.
- Move COMMON_EVENT_TYPES.forEach() after runMain() to avoid TDZ issue
- Remove early return in addEventListeners() to preserve custom event discovery
- Keep TreeWalker optimization for all scans (more memory efficient)
- Include root element when scanning subtrees

Addresses review comments from PR #55
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark 'Rendering Engine Throughput'.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.05.

Benchmark suite Current: d4fdfbe Previous: 7850fba Ratio
Abies.Benchmarks.Diffing/SmallDomDiff 519.9397257396153 ns (± 0.9754117043919243) 494.8377917607625 ns (± 1.110715084276751) 1.05
Abies.Benchmarks.Handlers/CreateFormWithHandlers 669.7583176067898 ns (± 4.394004905539888) 625.0770479348989 ns (± 1.982414264642316) 1.07

This comment was automatically generated by workflow using github-action-benchmark.

CC: @MCGPPeters

@MCGPPeters MCGPPeters merged commit 3dd327e into main Feb 9, 2026
13 checks passed
@MCGPPeters MCGPPeters deleted the perf/event-delegation branch February 9, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants