Skip to content

Commit 3dd327e

Browse files
authored
perf: Pre-register event listeners to avoid DOM scanning (#55)
* perf: pre-register event listeners to avoid DOM scanning - 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. * fix: address review comments for event listener pre-registration - 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
1 parent 2c0c578 commit 3dd327e

File tree

3 files changed

+276
-117
lines changed

3 files changed

+276
-117
lines changed

Abies.Presentation/wwwroot/abies.js

Lines changed: 92 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,9 @@ void (async () => {
213213
try { tracer = trace.getTracer('Abies.JS'); } catch {}
214214
// Expose OTel handle for forceFlush on page unload
215215
try {
216-
window.__otel = {
217-
provider,
218-
exporter,
216+
window.__otel = {
217+
provider,
218+
exporter,
219219
endpoint: guessOtlp(),
220220
// Expose verbosity controls
221221
getVerbosity,
@@ -255,21 +255,21 @@ void (async () => {
255255
// currentSpan: the span that is currently active (for creating children)
256256
// activeTraceContext: persists trace context even after span ends (for fetch calls)
257257
const state = { currentSpan: null, activeTraceContext: null, pendingSpans: [] };
258-
258+
259259
function makeSpan(name, kind = 1, explicitParent = undefined) {
260260
// Use explicit parent if provided, otherwise use current span, otherwise use active trace context
261261
const parent = explicitParent !== undefined ? explicitParent : (state.currentSpan || state.activeTraceContext);
262262
const traceId = parent?.traceId || hex(16);
263263
const spanId = hex(8);
264264
return { traceId, spanId, parentSpanId: parent?.spanId, name, kind, start: nowNs(), end: null, attributes: {} };
265265
}
266-
266+
267267
// Batch and export spans in OTLP JSON format
268268
let exportTimer = null;
269269
async function flushSpans() {
270270
if (state.pendingSpans.length === 0) return;
271271
const spans = state.pendingSpans.splice(0, state.pendingSpans.length);
272-
272+
273273
// Build OTLP JSON payload
274274
const payload = {
275275
resourceSpans: [{
@@ -297,7 +297,7 @@ void (async () => {
297297
}]
298298
}]
299299
};
300-
300+
301301
try {
302302
await fetch(endpoint, {
303303
method: 'POST',
@@ -308,20 +308,20 @@ void (async () => {
308308
// Silently ignore export errors
309309
}
310310
}
311-
311+
312312
function scheduleFlush() {
313313
if (exportTimer) return;
314314
exportTimer = setTimeout(() => {
315315
exportTimer = null;
316316
flushSpans();
317317
}, 500);
318318
}
319-
319+
320320
async function exportSpan(span) {
321321
state.pendingSpans.push(span);
322322
scheduleFlush();
323323
}
324-
324+
325325
// Minimal shim tracer used by existing code paths
326326
trace = {
327327
getTracer: () => ({
@@ -340,8 +340,8 @@ void (async () => {
340340
setAttribute: (key, value) => { s.attributes[key] = value; },
341341
setStatus: () => {},
342342
recordException: () => {},
343-
end: async () => {
344-
s.end = nowNs();
343+
end: async () => {
344+
s.end = nowNs();
345345
state.currentSpan = prev;
346346
// Keep activeTraceContext alive briefly for async operations (fetch)
347347
// Clear it after a short delay to allow pending fetches to inherit context
@@ -350,7 +350,7 @@ void (async () => {
350350
state.activeTraceContext = prev;
351351
}
352352
}, 100);
353-
await exportSpan(s);
353+
await exportSpan(s);
354354
}
355355
};
356356
}
@@ -367,23 +367,23 @@ void (async () => {
367367
const url = (typeof input === 'string') ? input : input.url;
368368
// Don't instrument OTLP export calls (would cause infinite loop)
369369
if (/\/otlp\/v1\/traces$/.test(url)) return origFetch(input, init);
370-
370+
371371
const method = (init && init.method) || (typeof input !== 'string' && input.method) || 'GET';
372-
372+
373373
// Create HTTP span as child of current span OR active trace context
374374
// This ensures fetch calls made after span.end() still link to the trace
375375
const parent = state.currentSpan || state.activeTraceContext;
376376
const sp = makeSpan(`HTTP ${method}`, 3 /* CLIENT */, parent);
377377
sp.attributes['http.method'] = method;
378378
sp.attributes['http.url'] = url;
379-
379+
380380
// Build W3C traceparent header to propagate to backend
381381
const traceparent = `00-${sp.traceId}-${sp.spanId}-01`;
382382
const i = init ? { ...init } : {};
383383
const h = new Headers((i && i.headers) || (typeof input !== 'string' && input.headers) || {});
384384
h.set('traceparent', traceparent);
385385
i.headers = h;
386-
386+
387387
try {
388388
const res = await origFetch(input, i);
389389
sp.attributes['http.status_code'] = res.status;
@@ -400,9 +400,9 @@ void (async () => {
400400
} catch {}
401401

402402
// Expose OTel handle for forceFlush on page unload
403-
window.__otel = {
404-
provider: { forceFlush: async () => { await flushSpans(); } },
405-
exporter: { url: endpoint },
403+
window.__otel = {
404+
provider: { forceFlush: async () => { await flushSpans(); } },
405+
exporter: { url: endpoint },
406406
endpoint,
407407
// Expose verbosity controls
408408
getVerbosity,
@@ -451,6 +451,36 @@ const { setModuleImports, getAssemblyExports, getConfig, runMain } = await dotne
451451

452452
const registeredEvents = new Set();
453453

454+
// Pre-register all common event types at startup to avoid O(n) DOM scanning
455+
// on every incremental update. These match the event types defined in Operations.cs.
456+
const COMMON_EVENT_TYPES = [
457+
// Mouse events
458+
'click', 'dblclick', 'mousedown', 'mouseup', 'mouseover', 'mouseout',
459+
'mouseenter', 'mouseleave', 'mousemove', 'contextmenu', 'wheel',
460+
// Keyboard events
461+
'keydown', 'keyup', 'keypress',
462+
// Form events
463+
'input', 'change', 'submit', 'reset', 'focus', 'blur', 'invalid', 'search',
464+
// Touch events
465+
'touchstart', 'touchend', 'touchmove', 'touchcancel',
466+
// Pointer events
467+
'pointerdown', 'pointerup', 'pointermove', 'pointercancel',
468+
'pointerover', 'pointerout', 'pointerenter', 'pointerleave',
469+
'gotpointercapture', 'lostpointercapture',
470+
// Drag events
471+
'drag', 'dragstart', 'dragend', 'dragenter', 'dragleave', 'dragover', 'drop',
472+
// Clipboard events
473+
'copy', 'cut', 'paste',
474+
// Media events
475+
'play', 'pause', 'ended', 'volumechange', 'timeupdate', 'seeking', 'seeked',
476+
'loadeddata', 'loadedmetadata', 'canplay', 'canplaythrough', 'playing',
477+
'waiting', 'stalled', 'suspend', 'emptied', 'ratechange', 'durationchange',
478+
// Other events
479+
'scroll', 'resize', 'load', 'error', 'abort', 'select', 'toggle',
480+
'animationstart', 'animationend', 'animationiteration', 'animationcancel',
481+
'transitionstart', 'transitionend', 'transitionrun', 'transitioncancel'
482+
];
483+
454484
function ensureEventListener(eventName) {
455485
if (registeredEvents.has(eventName)) return;
456486
// Attach to document to survive body innerHTML changes and use capture for early handling
@@ -500,14 +530,14 @@ function genericEventHandler(event) {
500530
if (name === 'keydown' && event && event.key === 'Enter') {
501531
try { event.preventDefault(); } catch { /* ignore */ }
502532
}
503-
533+
504534
// Build rich UI context for tracing
505535
const tag = (target.tagName || '').toLowerCase();
506536
const text = (target.textContent || '').trim().substring(0, 50); // Truncate long text
507537
const classes = target.className || '';
508538
const ariaLabel = target.getAttribute('aria-label') || '';
509539
const elId = target.id || '';
510-
540+
511541
// Build human-readable action description
512542
let action = '';
513543
if (name === 'click') {
@@ -528,7 +558,7 @@ function genericEventHandler(event) {
528558
} else {
529559
action = `${name}: ${tag}${elId ? '#' + elId : ''}`;
530560
}
531-
561+
532562
const spanOptions = {
533563
attributes: {
534564
'ui.event.type': name,
@@ -541,7 +571,7 @@ function genericEventHandler(event) {
541571
'abies.message_id': message
542572
}
543573
};
544-
574+
545575
// Use startActiveSpan if available (CDN mode) to properly set context for nested spans
546576
// This ensures FetchInstrumentation creates child spans under this UI Event
547577
if (typeof tracer.startActiveSpan === 'function') {
@@ -811,27 +841,45 @@ function unsubscribe(key) {
811841
}
812842

813843
/**
814-
* Adds event listeners to the document body for interactive elements.
844+
* Discovers and registers event listeners for any custom (non-common) event types
845+
* in the given DOM subtree. Common event types are pre-registered at startup,
846+
* so this function primarily handles rare/custom event handlers.
847+
*
848+
* Uses TreeWalker instead of querySelectorAll for better memory efficiency.
815849
*/
816850
function addEventListeners(root) {
851+
// Scan the given scope for any data-event-* attributes.
852+
// Since common events are pre-registered, this is mostly a no-op for typical apps,
853+
// but we still need to scan for custom event types in newly added HTML.
817854
const scope = root || document;
818-
// Build a list including the scope element (if Element) plus all descendants
819-
const nodes = [];
820-
if (scope && scope.nodeType === 1 /* ELEMENT_NODE */) nodes.push(scope);
821-
scope.querySelectorAll('*').forEach(el => {
822-
nodes.push(el);
823-
});
824-
nodes.forEach(el => {
825-
for (const attr of el.attributes) {
855+
856+
// Use TreeWalker for memory-efficient iteration
857+
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_ELEMENT);
858+
859+
// Include the root element itself if it's an element
860+
if (scope.nodeType === 1 /* ELEMENT_NODE */ && scope.attributes) {
861+
for (const attr of scope.attributes) {
826862
if (attr.name.startsWith('data-event-')) {
827863
const name = attr.name.substring('data-event-'.length);
828864
ensureEventListener(name);
829865
}
830866
}
831-
});
832-
}
833-
834-
/**
867+
}
868+
869+
// Walk descendants
870+
let el = walker.nextNode();
871+
while (el) {
872+
if (el.attributes) {
873+
for (const attr of el.attributes) {
874+
if (attr.name.startsWith('data-event-')) {
875+
const name = attr.name.substring('data-event-'.length);
876+
ensureEventListener(name);
877+
}
878+
}
879+
}
880+
el = walker.nextNode();
881+
}
882+
}/**
835883
* Event handler for click events on elements with data-event-* attributes.
836884
* @param {Event} event - The DOM event.
837885
*/
@@ -896,7 +944,7 @@ setModuleImports('abies.js', {
896944
setTitle: withSpan('setTitle', async (title) => {
897945
document.title = title;
898946
}),
899-
947+
900948
/**
901949
* Removes a child element from the DOM.
902950
* @param {number} parentId - The ID of the parent element.
@@ -1345,11 +1393,16 @@ setModuleImports('abies.js', {
13451393
unsubscribe(key);
13461394
}
13471395
});
1348-
1396+
13491397
const config = getConfig();
13501398
const exports = await getAssemblyExports("Abies");
13511399

13521400
await runMain(); // Ensure the .NET runtime is initialized
13531401

1402+
// Pre-register all common event types now that the runtime is ready.
1403+
// This is done after runMain() to avoid TDZ issues with exports.
1404+
// Since ensureEventListener checks registeredEvents Set, this is idempotent.
1405+
COMMON_EVENT_TYPES.forEach(ensureEventListener);
1406+
13541407
// Make sure any existing data-event-* attributes in the initial DOM are discovered
13551408
try { addEventListeners(); } catch (err) { /* ignore */ }

0 commit comments

Comments
 (0)