Skip to content

lazy-define scan() schedules per-element requestAnimationFrame, delaying paint on bulk DOM mutations #343

@alexus37

Description

@alexus37

Summary

When many elements are added to the DOM in a single frame (e.g., a framework rendering a list), the scan() function in lazy-define.ts schedules a separate requestAnimationFrame callback for each element. Since all rAF callbacks run before the browser paints, this delays the first paint proportionally to the number of added elements.

Details

The scan() function uses a WeakMap<ElementLike, number> keyed by each individual element to deduplicate:

const timers = new WeakMap<ElementLike, number>()
function scan(element: ElementLike) {
  cancelAnimationFrame(timers.get(element) || 0)
  timers.set(
    element,
    requestAnimationFrame(() => {
      for (const tagName of dynamicElements.keys()) {
        const child = element instanceof Element && element.matches(tagName)
          ? element : element.querySelector(tagName)
        // ...
      }
    })
  )
}

The cancelAnimationFrame/requestAnimationFrame pairing is intended to coalesce multiple calls — but only works when scan() is called repeatedly with the same element.

The MutationObserver in observe() calls scan() with a different element for each added node:

elementLoader ||= new MutationObserver(mutations => {
  if (!dynamicElements.size) return
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node instanceof Element) scan(node)  // different element each time
    }
  }
})

Since each element gets its own WeakMap entry and its own rAF, none of the cancelAnimationFrame calls actually cancel anything.

Impact

When a framework commits a list of 25 items (each with ~5 child elements), this creates ~127 rAF callbacks that all execute before the browser can paint. In profiling, this adds ~17ms of JS execution and, more importantly, delays paint by a full animation frame because the browser cannot paint until all queued rAF callbacks finish.

The browser frame lifecycle is: JS tasks → rAF callbacks → Style/Layout → Paint. Flooding the rAF queue pushes paint later than necessary.

Suggested Fix

Use a single shared rAF timer that coalesces all elements from one mutation batch:

const pendingElements = new Set<ElementLike>()
let scanTimer: number | null = null

function scheduleScan(element: ElementLike) {
  pendingElements.add(element)
  if (scanTimer == null) {
    scanTimer = requestAnimationFrame(() => {
      scanTimer = null
      if (!dynamicElements.size) {
        pendingElements.clear()
        return
      }
      for (const el of pendingElements) {
        for (const tagName of dynamicElements.keys()) {
          const child = el instanceof Element && el.matches(tagName)
            ? el : el.querySelector(tagName)
          if (customElements.get(tagName) || child) {
            const strategyName = child?.getAttribute("data-load-on") || "ready"
            const strategy = strategyName in strategies ? strategies[strategyName] : strategies.ready
            for (const cb of dynamicElements.get(tagName) || []) strategy(tagName).then(cb)
            dynamicElements.delete(tagName)
          }
        }
      }
      pendingElements.clear()
    })
  }
}

This ensures that no matter how many elements are added in a single frame, only one rAF callback runs before paint.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions