Skip to content

perf: throttle global resize handler + guard instance-less wrappers#472

Merged
gnbm merged 13 commits into
security/sanitize-classnamesfrom
perf/throttle-resize-handler
Jun 8, 2026
Merged

perf: throttle global resize handler + guard instance-less wrappers#472
gnbm merged 13 commits into
security/sanitize-classnamesfrom
perf/throttle-resize-handler

Conversation

@gnbm

@gnbm gnbm commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

static onResizeMethod() {
  document.querySelectorAll('.vscomp-ele-wrapper').forEach(($ele) => {
    $ele.parentElement.virtualSelect.onResize(); // -> setOptionsContainerHeight(true)
  });
}
window.addEventListener('resize', VirtualSelect.onResizeMethod);
  • The listener is unthrottled, so during a resize drag it runs full-document queries + per-instance height recomputation on every tick — O(instances) layout work per event.
  • It assumes every .vscomp-ele-wrapper's parent has a .virtualSelect and throws (Cannot read properties of undefined) if one is mid-teardown.

Fix

  • Add Utils.throttle() and wrap the resize listener at ~100 ms, so per-instance work runs at most ~10×/sec.
  • Utils.throttle() preserves the caller's this (invokes the callback via callback.apply(...) on both leading and trailing edges), keeping it safe as a general-purpose utility — addresses code-review feedback.
  • Guard onResizeMethod so wrappers without an instance are skipped.

No-regression note: closed instances are still updated. Opening a dropdown does not recompute the options-container height (only render-time and resize do), so skipping closed instances could leave a stale height on reopen. Throttling delivers the perf win without that risk; skipping-closed would require recompute-on-open and is left as a possible follow-up.

Verification

New spec cypress/e2e/perf-resize-throttle.cy.ts:

  • throttling: 10 rapid resize events → onResize runs >0 and <10 times;
  • no-regression: an open dropdown stays open/functional after a resize;
  • guard: an orphan .vscomp-ele-wrapper (no instance) doesn't throw on resize.

Other Validations:

  • Ran npm run validate for static code validation: image
  • npm run build: succeeds; dist/docs rebuilt
  • All tests passed (ran npm run test ):
image

…wrappers (P1)

The window 'resize' listener ran onResizeMethod on every tick, which queried all
.vscomp-ele-wrapper elements and recomputed each instance's options-container
height - O(instances) layout work per resize event during a drag. It also assumed
every wrapper's parent had a .virtualSelect and threw otherwise.

- Add Utils.throttle and wrap the resize listener (~100ms) so the per-instance
  work runs at most ~10x/sec.
- Guard onResizeMethod against wrappers whose instance is missing (teardown races).

Closed instances are still updated (opening does not recompute height, so skipping
them could leave a stale height on reopen) - throttling delivers the win without
that regression risk.

Adds cypress spec perf-resize-throttle.cy.ts (throttling, no-regression, guard).
gnbm added 2 commits June 4, 2026 00:06
Fix lint issues
Add JSDoc/type annotations to Utils.throttle (timeout and lastArgs) and annotate the throttled function parameter. Replace callback.apply(this, lastArgs) with callback(...lastArgs) for simpler invocation and improved type-safety. Updated the built/minified distribution and docs asset files accordingly.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 improves resize-time performance and resilience by throttling VirtualSelect’s global window.resize handler and guarding against .vscomp-ele-wrapper elements whose instances are missing (e.g., during teardown). It also adds a Cypress regression spec and rebuilds the distributed/docs bundles.

Changes:

  • Throttle the global resize listener to ~100ms and skip wrappers without an attached virtualSelect instance.
  • Add Utils.throttle() helper to support rate-limiting high-frequency handlers.
  • Add Cypress E2E coverage for throttling behavior, no-regression on open dropdowns, and teardown-guard behavior.

Reviewed changes

Copilot reviewed 4 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/virtual-select.js Adds instance guard in onResizeMethod and wraps the resize listener with Utils.throttle(..., 100).
src/utils/utils.js Introduces Utils.throttle() utility used by the global resize handler.
cypress/e2e/perf-resize-throttle.cy.ts Adds E2E tests for throttling, regression (dropdown remains functional), and orphan-wrapper guard.
dist/virtual-select.js Rebuilt bundle reflecting the new throttle + guard behavior.
dist/virtual-select.min.js Rebuilt minified bundle reflecting the new throttle + guard behavior.
docs/assets/virtual-select.js Rebuilt docs bundle reflecting the new throttle + guard behavior.
docs/assets/virtual-select.min.js Rebuilt minified docs bundle reflecting the new throttle + guard behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/utils/utils.js

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 8 changed files in this pull request and generated 1 comment.

Comment thread src/utils/utils.js
Invoke the throttled callback via callback.apply(lastThis, lastArgs) on
both leading and trailing edges so instance methods used as throttled
event handlers run with the expected 'this'. Add @this annotation to
satisfy tsc. Regenerate dist/docs bundles.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 8 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 8 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 8 changed files in this pull request and generated 1 comment.

Comment thread docs/assets/virtual-select.js
gnbm and others added 3 commits June 8, 2026 12:13
…leanup gaps) (#468)

* fix: remove document click listener correctly to prevent memory leak

onDocumentClick is added to `document` in the capture phase
(addEvent(document, 'click', 'onDocumentClick', true)), but removeEvent called
removeEventListener without the capture flag. removeEventListener only
unregisters a listener when its capture flag matches the one used at
registration, so the capture-phase document listener was never removed.

As a result, every render/destroy cycle (e.g. repeatedly opening and closing
the dropdown) left a new onDocumentClick listener bound to `document`. Each
leaked listener retains the VirtualSelect instance and its detached dropbox DOM
subtree, so event-listener and DOM-node counts grow without bound and are never
reclaimed by GC.

Fix: thread the capture flag through DomUtils.removeEvent and the
VirtualSelect.removeEvent wrapper, and pass capture: true when removing the
document click listener so add and remove match.

* fix: ensure cleanup runs in inline mode and clear retained references on destroy

Two additional memory-leak hardening fixes:

1. Install the self-destroy MutationObserver in every mode, not only when
   hasDropboxWrapper is true. Previously, inline-mode dropdowns had no observer,
   so removing the host element from the DOM without calling destroy() left all
   addEvents() listeners attached - including the capture-phase document click
   listener that retains the instance and its detached DOM. removeMutationObserver
   now disconnects based on the observer's existence.

2. On destroy(), clear the $dropboxWrapper.virtualSelect back-reference (set in
   setEleProps) before detaching the wrapper, and reset this.events. This breaks
   the instance <-> detached-DOM reference cycle so the instance and its DOM can
   be reclaimed.

* fix: limit mutation observer to popup mode to avoid per-instance perf penalty

Reverts the all-modes MutationObserver install. In the default
dropboxWrapper:'self' mode every instance was registering a body-wide
subtree observer, adding O(instances x mutations) overhead on pages with
many selects. The reported document-click listener leak is fixed by the
capture-flag correction in removeEvents(); the observer stays scoped to
popup mode as before.
The branch committed the unminified dist/docs sources and the dist-archive
copy with the security (S2 customData escaping, S3 classNames sanitization)
and perf (P1 throttled resize) fixes, but the primary distributed bundles
(dist/virtual-select.min.js and docs/assets/virtual-select.min.js) were left
stale - consumers loading them got none of the fixes.

Regenerated via 'npm run build' so all three min bundles share an identical
body and match the source.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread src/virtual-select.js Outdated
Comment thread docs/assets/virtual-select.js
…review)

The throttled wrapper passed inline to addEventListener was anonymous, so the
resize listener could no longer be detached via removeEventListener (the prior
VirtualSelect.onResizeMethod reference was removable). Store the throttled
function on VirtualSelect.onResizeThrottled and register that reference instead,
restoring removability for future cleanup. Declared as a typed static field so
it passes tsc (checkJs + strict).
@gnbm gnbm requested a review from Copilot June 8, 2026 12:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread docs/assets/virtual-select.js
Comment thread src/virtual-select.js Outdated
gnbm and others added 2 commits June 8, 2026 13:36
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread src/virtual-select.js Outdated
Comment thread docs/assets/virtual-select.js
…Copilot review)

The static class-field declaration provided no type-safety benefit (the file
is // @ts-nocheck, so tsc never checks it) and used ES2022 class-field syntax
not present anywhere else in the codebase. The property is assigned at module
init time via VirtualSelect.onResizeThrottled = ..., which is sufficient to
expose a stable, removable reference. Runtime behaviour is unchanged.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread src/utils/utils.js
Comment thread docs/assets/virtual-select.js
…iew)

throttle retained lastArgs/lastThis (e.g. a DOM Event passed to a resize
handler) until the next call or indefinitely if never called again. Clear them
after both the leading and trailing invocations so large arguments aren't held,
matching the canonical underscore/lodash throttle. Behaviour is unchanged:
both paths reassign lastArgs/lastThis at the top of every call before use.
@gnbm gnbm requested a review from Copilot June 8, 2026 12:58
@gnbm gnbm marked this pull request as ready for review June 8, 2026 12:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 9 changed files in this pull request and generated 1 comment.

Comment thread docs/assets/virtual-select.js

@os-davidlourenco os-davidlourenco left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@gnbm gnbm merged commit 6082f1c into security/sanitize-classnames Jun 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants