fix(domtools): stabilize DomTools lifecycle, cleanup, and singleton behavior

This commit is contained in:
2026-04-24 04:54:40 +00:00
parent 6c5271015d
commit 06f0ea4f92
10 changed files with 781 additions and 163 deletions
+80 -29
View File
@@ -3,19 +3,22 @@ import * as plugins from './domtools.plugins.js';
export class Scroller {
public domtoolsInstance: DomTools;
private disposed = false;
// Array to store scroll callback functions.
private scrollCallbacks: Array<() => void> = [];
// Lenis instance (if activated) or null.
private lenisInstance: plugins.Lenis | null = null;
private lenisScrollUnsubscribe: (() => void) | null = null;
private nativeScrollListenerAttached = false;
// Bound handlers to allow removal from event listeners.
private handleNativeScroll = (event: Event): void => {
private handleNativeScroll = (_event: Event): void => {
this.executeScrollCallbacks();
};
private handleLenisScroll = (info: any): void => {
private handleLenisScroll = (_info: plugins.Lenis): void => {
this.executeScrollCallbacks();
};
@@ -44,23 +47,40 @@ export class Scroller {
* Detects whether native smooth scrolling is enabled.
*/
public async detectNativeSmoothScroll() {
const rootScrollBehavior = getComputedStyle(document.documentElement).scrollBehavior;
const bodyScrollBehavior = document.body ? getComputedStyle(document.body).scrollBehavior : 'auto';
if (rootScrollBehavior === 'smooth' || bodyScrollBehavior === 'smooth') {
return true;
}
const done = plugins.smartpromise.defer<boolean>();
const sampleSize = 100;
const sampleSize = 12;
const acceptableDeltaDifference = 3;
const minimumSmoothRatio = 0.75;
const timeoutInMs = 1200;
const eventDeltas: number[] = [];
const finalize = (result: boolean) => {
window.removeEventListener('wheel', onWheel);
window.clearTimeout(timeoutId);
done.resolve(result);
};
function onWheel(event: WheelEvent) {
eventDeltas.push(event.deltaY);
if (eventDeltas.length >= sampleSize) {
window.removeEventListener('wheel', onWheel);
analyzeEvents();
}
}
function analyzeEvents() {
if (eventDeltas.length < 2) {
finalize(false);
return;
}
const totalDiffs = eventDeltas.length - 1;
let smallDiffCount = 0;
@@ -72,16 +92,14 @@ export class Scroller {
}
const smoothRatio = smallDiffCount / totalDiffs;
if (smoothRatio >= minimumSmoothRatio) {
console.log('Smooth scrolling detected.');
done.resolve(true);
} else {
console.log('Smooth scrolling NOT detected.');
done.resolve(false);
}
finalize(smoothRatio >= minimumSmoothRatio);
}
window.addEventListener('wheel', onWheel);
const timeoutId = window.setTimeout(() => {
analyzeEvents();
}, timeoutInMs);
window.addEventListener('wheel', onWheel, { passive: true });
return done.promise;
}
@@ -91,6 +109,14 @@ export class Scroller {
* Lenis will be destroyed immediately.
*/
public async enableLenisScroll(optionsArg?: { disableOnNativeSmoothScroll?: boolean }) {
if (this.disposed) {
return;
}
if (this.lenisInstance) {
this.disableLenisScroll();
}
const lenis = new plugins.Lenis({
autoRaf: true,
});
@@ -107,24 +133,28 @@ export class Scroller {
// Switch from native scroll listener to Lenis scroll listener.
this.detachNativeScrollListener();
this.attachLenisScrollListener();
}
// Monkey-patch the destroy method so that when Lenis is destroyed,
// the native scroll listener is reattached.
const originalDestroy = lenis.destroy.bind(lenis);
lenis.destroy = () => {
originalDestroy();
this.detachLenisScrollListener();
this.attachNativeScrollListener();
this.lenisInstance = null;
};
public disableLenisScroll() {
if (!this.lenisInstance) {
return;
}
this.detachLenisScrollListener();
this.lenisInstance.destroy();
this.lenisInstance = null;
this.attachNativeScrollListener();
}
/**
* Registers a callback to be executed on scroll.
* @param callback A function to execute on each scroll event.
*/
public onScroll(callback: () => void): void {
public onScroll(callback: () => void): () => void {
this.scrollCallbacks.push(callback);
return () => {
this.scrollCallbacks = this.scrollCallbacks.filter((existingCallback) => existingCallback !== callback);
};
}
/**
@@ -145,23 +175,30 @@ export class Scroller {
* Attaches the native scroll event listener.
*/
private attachNativeScrollListener(): void {
if (this.nativeScrollListenerAttached || this.disposed) {
return;
}
window.addEventListener('scroll', this.handleNativeScroll);
this.nativeScrollListenerAttached = true;
}
/**
* Detaches the native scroll event listener.
*/
private detachNativeScrollListener(): void {
if (!this.nativeScrollListenerAttached) {
return;
}
window.removeEventListener('scroll', this.handleNativeScroll);
this.nativeScrollListenerAttached = false;
}
/**
* Attaches the Lenis scroll event listener.
*/
private attachLenisScrollListener(): void {
if (this.lenisInstance) {
// Assuming that Lenis exposes an `on` method to listen to scroll events.
this.lenisInstance.on('scroll', this.handleLenisScroll);
if (this.lenisInstance && !this.lenisScrollUnsubscribe) {
this.lenisScrollUnsubscribe = this.lenisInstance.on('scroll', this.handleLenisScroll);
}
}
@@ -169,9 +206,23 @@ export class Scroller {
* Detaches the Lenis scroll event listener.
*/
private detachLenisScrollListener(): void {
if (this.lenisInstance) {
// Assuming that Lenis exposes an `off` method to remove scroll event listeners.
this.lenisInstance.off('scroll', this.handleLenisScroll);
if (this.lenisScrollUnsubscribe) {
this.lenisScrollUnsubscribe();
this.lenisScrollUnsubscribe = null;
}
}
}
public dispose() {
if (this.disposed) {
return;
}
this.disposed = true;
this.detachLenisScrollListener();
this.lenisInstance?.destroy();
this.lenisInstance = null;
this.detachNativeScrollListener();
this.scrollCallbacks = [];
this.sweetScroller.destroy();
}
}