Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions cypress/e2e/perf-resize-throttle.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/** cSpell:ignore vscomp */

// Tests for P1: the global window 'resize' handler used to run per-instance height
// recomputation on every resize tick, unthrottled, and threw if a wrapper had no instance.

describe('Performance: window resize handler (P1)', () => {
const mountId = 'vs-p1-resize';

const mount = (win: Window) => {
const doc = win.document;
const existing = doc.getElementById(mountId);
if (existing) {
existing.remove();
}
const $ele = doc.createElement('div');
$ele.id = mountId;
doc.body.appendChild($ele);
// @ts-expect-error - VirtualSelect attached to window by the bundle
win.VirtualSelect.init({ ele: $ele, options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }] });
return $ele;
};

it('throttles resize so onResize runs far fewer times than resize events fire', () => {
cy.visit('get-started');
cy.window().then((win) => {
mount(win);
// @ts-expect-error - instance back-reference
const instance = win.document.getElementById(mountId).virtualSelect;
cy.spy(instance, 'onResize').as('onResize');

for (let i = 0; i < 10; i += 1) {
win.dispatchEvent(new win.Event('resize'));
}
});

// allow the trailing-edge call to fire
cy.wait(250);
cy.get('@onResize').its('callCount').should('be.greaterThan', 0).and('be.lessThan', 10);
});

it('still updates the dropdown on resize (no regression)', () => {
cy.visit('get-started');
cy.window().then((win) => mount(win));

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
cy.get(`#${mountId}`).find('.vscomp-option').should('have.length.greaterThan', 0);

cy.window().then((win) => win.dispatchEvent(new win.Event('resize')));
cy.wait(150);

// dropdown remains open and functional after a resize
cy.get(`#${mountId}`).find('.vscomp-ele-wrapper').should('not.have.class', 'closed');
cy.get(`#${mountId}`).find('.vscomp-options-container').should('exist');
});

it('does not throw when a wrapper has no associated instance (guard)', () => {
cy.visit('get-started');
cy.window().then((win) => {
const doc = win.document;
const orphan = doc.createElement('div');
orphan.innerHTML = '<div class="vscomp-ele-wrapper"></div>'; // parent has no .virtualSelect
doc.body.appendChild(orphan);

// would throw "Cannot read properties of undefined (reading 'onResize')" without the guard
win.dispatchEvent(new win.Event('resize'));
});

// if the handler threw, Cypress would have failed the test on the uncaught exception
cy.get('body').should('exist');
});
});
2 changes: 1 addition & 1 deletion dist-archive/virtual-select-1.2.0.min.js

Large diffs are not rendered by default.

93 changes: 85 additions & 8 deletions dist/virtual-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,54 @@ class Utils {
static sanitizeClassNames(classNames) {
return classNames ? String(classNames).replace(/["<>]/g, '') : classNames;
}

/**
* Rate-limit a function so it runs at most once per `wait` ms (leading + trailing edge).
* Used to keep high-frequency events (e.g. window resize) from running per-instance work
* on every tick.
* @static
* @param {Function} callback
* @param {number} wait
* @return {Function}
* @memberof Utils
*/
static throttle(callback, wait) {
/** @type {ReturnType<typeof setTimeout> | null} */
let timeout = null;
/** @type {unknown[]} */
let lastArgs = [];
/** @type {unknown} */
let lastThis;
let previous = 0;

/** @this {unknown} */
return function throttled(/** @type {unknown[]} */...args) {
const now = Date.now();
const remaining = wait - (now - previous);
lastArgs = args;
lastThis = this;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
callback.apply(lastThis, lastArgs);
/** release references so a large last argument (e.g. a DOM Event) isn't retained */
lastArgs = [];
lastThis = undefined;
} else if (!timeout) {
timeout = setTimeout(() => {
previous = Date.now();
timeout = null;
callback.apply(lastThis, lastArgs);
/** release references so a large last argument (e.g. a DOM Event) isn't retained */
lastArgs = [];
lastThis = undefined;
}, remaining);
}
};
}
}
;// ./src/utils/dom-utils.js

Expand Down Expand Up @@ -552,14 +600,17 @@ class DomUtils {
* @param {HTMLElement} $ele
* @param {string} event
* @param {Function} callback
* @param {boolean} [capture] - must match the value passed to addEvent, otherwise the listener is NOT removed
*/
static removeEvent($ele, event, callback) {
static removeEvent($ele, event, callback, capture = false) {
if (!$ele) {
return;
}
const $eleArray = DomUtils.getElements($ele);
$eleArray.forEach($this => {
$this.removeEventListener(event, callback);
$this.removeEventListener(event, callback, {
capture
});
});
}
}
Expand Down Expand Up @@ -956,7 +1007,12 @@ class VirtualSelect {

/** dom event methods - start */
removeEvents() {
this.removeEvent(document, 'click', 'onDocumentClick');
/**
* onDocumentClick is registered in the capture phase (see addEvents). The capture flag
* MUST match here, otherwise removeEventListener is a no-op and the listener (and the
* VirtualSelect instance + detached DOM it closes over) leaks on every destroy/re-render.
*/
this.removeEvent(document, 'click', 'onDocumentClick', true);
this.removeEvent(this.$allWrappers, 'keydown', 'onKeyDown');
this.removeEvent(this.$toggleButton, 'click keydown', 'onToggleButtonPress');
this.removeEvent(this.$clearButton, 'click keydown', 'onClearButtonClick');
Expand Down Expand Up @@ -987,7 +1043,7 @@ class VirtualSelect {
}
this.removeMutationObserver();
}
removeEvent($ele, events, method) {
removeEvent($ele, events, method, capture = false) {
if (!$ele) {
return;
}
Expand All @@ -996,7 +1052,7 @@ class VirtualSelect {
const eventsKey = `${method}-${event}`;
const callback = this.events[eventsKey];
if (callback) {
DomUtils.removeEvent($ele, event, callback);
DomUtils.removeEvent($ele, event, callback, capture);
}
});
}
Expand Down Expand Up @@ -1214,8 +1270,9 @@ class VirtualSelect {
});
}
removeMutationObserver() {
if (this.hasDropboxWrapper) {
if (this.mutationObserver) {
this.mutationObserver.disconnect();
this.mutationObserver = null;
}
}

Expand Down Expand Up @@ -3506,12 +3563,18 @@ class VirtualSelect {
/** Remove all event listeners to prevent memory leaks and ensure proper cleanup */
this.removeEvents();
if (this.hasDropboxWrapper) {
/** clear the back-reference (set in setEleProps) before detaching so the
* detached wrapper does not keep this instance and its DOM alive */
this.$dropboxWrapper.virtualSelect = undefined;
this.$dropboxWrapper.remove();
}
if (this.dropboxPopover) {
this.dropboxPopover.destroy();
}
DomUtils.removeClass($ele, 'vscomp-ele');

/** drop references to cached callbacks and DOM so nothing is retained after destroy */
this.events = {};
}
createSecureTextElements() {
this.$secureDiv = document.createElement('div');
Expand Down Expand Up @@ -3739,16 +3802,30 @@ class VirtualSelect {
static toggleRequiredMethod(isRequired) {
return this.virtualSelect.toggleRequired(isRequired);
}

// Stable reference to the throttled resize handler is assigned at module init time
// (see `VirtualSelect.onResizeThrottled = ...` near the global resize listener).

static onResizeMethod() {
document.querySelectorAll('.vscomp-ele-wrapper').forEach($ele => {
$ele.parentElement.virtualSelect.onResize();
/** guard against wrappers whose instance is mid-teardown / not initialised */
const instance = $ele.parentElement && $ele.parentElement.virtualSelect;
if (instance) {
instance.onResize();
}
});
}
/** static methods - end */
}
document.addEventListener('reset', VirtualSelect.onFormReset);
document.addEventListener('submit', VirtualSelect.onFormSubmit);
window.addEventListener('resize', VirtualSelect.onResizeMethod);
/**
* throttle resize so the per-instance height recompute runs at most ~10x/sec during a drag.
* Keep a stable reference on VirtualSelect so the listener can be removed later if needed.
*/
const onResizeThrottled = Utils.throttle(VirtualSelect.onResizeMethod, 100);
VirtualSelect.onResizeThrottled = onResizeThrottled;
window.addEventListener('resize', onResizeThrottled);
attrPropsMapping = VirtualSelect.getAttrProps();
window.VirtualSelect = VirtualSelect;

Expand Down
2 changes: 1 addition & 1 deletion dist/virtual-select.min.js

Large diffs are not rendered by default.

Loading