From 1edfae44db7a7fb89d1628ff8a4256297e6d8dd3 Mon Sep 17 00:00:00 2001 From: ivan Date: Fri, 6 Sep 2024 00:44:46 +0300 Subject: [PATCH] fix: #502 Scrollspy 3 event listener leaks --- spec/helpers/helper.js | 16 ++++- spec/tests/scrollspy/scrollspySpec.js | 90 +++++++++++++++++++++++++++ src/scrollspy.ts | 88 +++++++++++++------------- 3 files changed, 148 insertions(+), 46 deletions(-) create mode 100644 spec/tests/scrollspy/scrollspySpec.js diff --git a/spec/helpers/helper.js b/spec/helpers/helper.js index 2959b39fc7..8832fd4937 100644 --- a/spec/helpers/helper.js +++ b/spec/helpers/helper.js @@ -121,11 +121,23 @@ const KEYMAP = { '105': '9' }; -function XloadHtml(html) { +function XloadHtml(html, options) { + options = options ? options : {}; + const defaultOptions = { insertionType: 'append' }; + options = { + ...defaultOptions, + ...options + }; + const div = document.createElement('div'); div.classList.add('please-delete-me'); div.innerHTML = html; - document.body.appendChild(div); + + if (options.insertionType === 'append') { + document.body.appendChild(div); + } else if (options.insertionType === 'prepend') { + document.body.prepend(div); + } } function XunloadFixtures() { diff --git a/spec/tests/scrollspy/scrollspySpec.js b/spec/tests/scrollspy/scrollspySpec.js new file mode 100644 index 0000000000..c14bc51cd1 --- /dev/null +++ b/spec/tests/scrollspy/scrollspySpec.js @@ -0,0 +1,90 @@ +describe('Scrollspy Plugin', () => { + const fixture = ` +
+
+
+ introduction +
+
+ initialization +
+
+ options +
+
+ +
+
+
+ +
+
+
+
+ `; + let scrollspyInstances = []; + + beforeEach(() => { + XloadHtml(fixture, { insertionType: 'prepend' }); + window.scrollTo(0, 0); + const elements = document.querySelectorAll('.scrollspy'); + scrollspyInstances = M.ScrollSpy.init(elements, {}); + }); + + afterEach(() => { + scrollspyInstances.forEach((value) => value.destroy()); + XunloadFixtures(); + }); + + function resetScrollspy(options) { + options = options ? options : {}; + scrollspyInstances.forEach((value) => value.destroy()); + const elements = document.querySelectorAll('.scrollspy'); + scrollspyInstances = M.ScrollSpy.init(elements, options); + } + + function clickLink(value) { + document.querySelector(`a[href="#${value}"]`).click(); + } + + describe('Scrollspy basic test cases', () => { + it('Test scrollspy smooth behavior positive case', (done) => { + resetScrollspy({ behavior: 'smooth' }); + const viewportHeightPx = window.innerHeight; + + clickLink('options'); + setTimeout(() => { + const scrollTop = window.scrollY; + expect(scrollTop).toBe(viewportHeightPx * 2); + done(); + }, 900); + }); + + it('Test scrollspy smooth behavior negative case', (done) => { + resetScrollspy({ behavior: 'smooth' }); + const viewportHeightPx = window.innerHeight; + + clickLink('options'); + setTimeout(() => { + const scrollTop = window.scrollY; + expect(scrollTop) + .withContext("Scroll animation shouldn't reach the element in the given time") + .toBeLessThan(viewportHeightPx * 2); + done(); + }, 5); + }); + }); +}); diff --git a/src/scrollspy.ts b/src/scrollspy.ts index 143420310b..c490bcfbbd 100644 --- a/src/scrollspy.ts +++ b/src/scrollspy.ts @@ -56,7 +56,7 @@ export class ScrollSpy extends Component { this.tickId = -1; this.id = ScrollSpy._increment; this._setupEventHandlers(); - this._handleWindowScroll(); + ScrollSpy._handleWindowScroll(); } static get defaults(): ScrollSpyOptions { @@ -88,49 +88,7 @@ export class ScrollSpy extends Component { return (el as any).M_ScrollSpy; } - destroy() { - ScrollSpy._elements.splice(ScrollSpy._elements.indexOf(this), 1); - ScrollSpy._elementsInView.splice(ScrollSpy._elementsInView.indexOf(this), 1); - ScrollSpy._visibleElements.splice(ScrollSpy._visibleElements.indexOf(this.el), 1); - ScrollSpy._count--; - this._removeEventHandlers(); - const actElem = document.querySelector(this.options.getActiveElement(this.el.id)); - actElem.classList.remove(this.options.activeClass); - (this.el as any).M_ScrollSpy = undefined; - } - - _setupEventHandlers() { - if (ScrollSpy._count === 1) { - window.addEventListener('scroll', this._handleWindowScroll); - window.addEventListener('resize', this._handleThrottledResize); - document.body.addEventListener('click', this._handleTriggerClick); - } - } - - _removeEventHandlers() { - if (ScrollSpy._count === 0) { - window.removeEventListener('scroll', this._handleWindowScroll); - window.removeEventListener('resize', this._handleThrottledResize); - document.body.removeEventListener('click', this._handleTriggerClick); - } - } - - _handleThrottledResize: () => void = Utils.throttle(function(){ this._handleWindowScroll(); }, 200).bind(this); - - _handleTriggerClick = (e: MouseEvent) => { - const trigger = e.target; - for (let i = ScrollSpy._elements.length - 1; i >= 0; i--) { - const scrollspy = ScrollSpy._elements[i]; - const x = document.querySelector('a[href="#'+scrollspy.el.id+'"]'); - if (trigger === x) { - e.preventDefault(); - scrollspy.el.scrollIntoView({behavior: 'smooth'}); - break; - } - } - } - - _handleWindowScroll = () => { + private static _handleWindowScroll() { // unique tick id ScrollSpy._ticks++; @@ -167,6 +125,48 @@ export class ScrollSpy extends Component { ScrollSpy._elementsInView = intersections; } + private static _handleThrottledResize = Utils.throttle(function () { ScrollSpy._handleWindowScroll(); }, 200); + + private static _handleTriggerClick(e: MouseEvent) { + const trigger = e.target; + for (let i = ScrollSpy._elements.length - 1; i >= 0; i--) { + const scrollspy = ScrollSpy._elements[i]; + const x = document.querySelector('a[href="#' + scrollspy.el.id + '"]'); + if (trigger === x) { + e.preventDefault(); + scrollspy.el.scrollIntoView({ behavior: 'smooth' }); + break; + } + } + } + + destroy() { + ScrollSpy._elements.splice(ScrollSpy._elements.indexOf(this), 1); + ScrollSpy._elementsInView.splice(ScrollSpy._elementsInView.indexOf(this), 1); + ScrollSpy._visibleElements.splice(ScrollSpy._visibleElements.indexOf(this.el), 1); + ScrollSpy._count--; + this._removeEventHandlers(); + const actElem = document.querySelector(this.options.getActiveElement(this.el.id)); + actElem.classList.remove(this.options.activeClass); + (this.el as any).M_ScrollSpy = undefined; + } + + _setupEventHandlers() { + if (ScrollSpy._count === 1) { + window.addEventListener('scroll', ScrollSpy._handleWindowScroll); + window.addEventListener('resize', ScrollSpy._handleThrottledResize); + document.body.addEventListener('click', ScrollSpy._handleTriggerClick); + } + } + + _removeEventHandlers() { + if (ScrollSpy._count === 0) { + window.removeEventListener('scroll', ScrollSpy._handleWindowScroll); + window.removeEventListener('resize', ScrollSpy._handleThrottledResize); + document.body.removeEventListener('click', ScrollSpy._handleTriggerClick); + } + } + static _offset(el) { const box = el.getBoundingClientRect(); const docElem = document.documentElement;