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
+137 -82
View File
@@ -5,6 +5,10 @@ import { WebSetup } from '@push.rocks/websetup';
import { ThemeManager } from './domtools.classes.thememanager.js';
import { Keyboard } from './domtools.classes.keyboard.js';
declare global {
var deesDomTools: DomTools | undefined;
}
export interface IDomToolsState {
virtualViewport: TViewport;
jwt: string;
@@ -24,59 +28,43 @@ export class DomTools {
* setups domtools
*/
public static async setupDomTools(optionsArg: IDomToolsContructorOptions = {}): Promise<DomTools> {
// If initialization is already in progress and we're not ignoring global, wait for it
if (!optionsArg.ignoreGlobal && DomTools.initializationPromise) {
return await DomTools.initializationPromise;
}
// Create initialization promise to prevent race conditions
if (!optionsArg.ignoreGlobal) {
DomTools.initializationPromise = (async () => {
let domToolsInstance: DomTools;
if (!globalThis.deesDomTools) {
globalThis.deesDomTools = new DomTools(optionsArg);
domToolsInstance = globalThis.deesDomTools;
// lets make sure the dom is ready
const readyStateChangedFunc = () => {
if (document.readyState === 'interactive' || document.readyState === 'complete') {
domToolsInstance.elements.headElement = document.querySelector('head');
domToolsInstance.elements.bodyElement = document.querySelector('body');
// Initialize keyboard now that document.body exists
domToolsInstance.keyboard = new Keyboard(document.body);
domToolsInstance.domReady.resolve();
}
};
// Check current state immediately to avoid race condition
if (document.readyState === 'interactive' || document.readyState === 'complete') {
readyStateChangedFunc();
} else {
document.addEventListener('readystatechange', readyStateChangedFunc);
}
domToolsInstance.domToolsReady.resolve();
} else {
domToolsInstance = globalThis.deesDomTools;
}
await domToolsInstance.domToolsReady.promise;
return domToolsInstance;
})();
return await DomTools.initializationPromise;
} else {
// ignoreGlobal case - create isolated instance
if (optionsArg.ignoreGlobal) {
const domToolsInstance = new DomTools(optionsArg);
await domToolsInstance.initializationPromise;
return domToolsInstance;
}
if (globalThis.deesDomTools && !globalThis.deesDomTools.disposed) {
await globalThis.deesDomTools.initializationPromise;
return globalThis.deesDomTools;
}
if (!DomTools.initializationPromise) {
const domToolsInstance = new DomTools(optionsArg);
globalThis.deesDomTools = domToolsInstance;
DomTools.initializationPromise = domToolsInstance.initializationPromise
.then(() => domToolsInstance)
.catch((error) => {
if (globalThis.deesDomTools === domToolsInstance) {
globalThis.deesDomTools = undefined;
}
DomTools.initializationPromise = null;
throw error;
});
}
return await DomTools.initializationPromise;
}
/**
* if you can, use the static asysnc .setupDomTools() function instead since it is safer to use.
*/
public static getGlobalDomToolsSync(): DomTools {
const globalDomTools: DomTools = globalThis.deesDomTools;
if (!globalDomTools) {
const globalDomTools = globalThis.deesDomTools;
if (!globalDomTools || globalDomTools.disposed) {
throw new Error('You tried to access domtools synchronously too early');
}
return globalThis.deesDomTools;
return globalDomTools;
}
// ========
@@ -118,16 +106,68 @@ export class DomTools {
public scroller = new Scroller(this);
public themeManager = new ThemeManager(this);
public keyboard: Keyboard | null = null; // Initialized after DOM ready to avoid accessing document.body before it exists
public disposed = false;
public domToolsReady = plugins.smartpromise.defer();
public domReady = plugins.smartpromise.defer();
public globalStylesReady = plugins.smartpromise.defer();
constructor(optionsArg: IDomToolsContructorOptions) {}
private readonly initializationPromise: Promise<void>;
private readonly managedDomNodes: Element[] = [];
private readonly readyStateChangedFunc = () => {
this.tryMarkDomReady();
};
private runOnceTrackerStringMap = new plugins.lik.Stringmap();
private runOnceResultMap = new plugins.lik.FastMap();
private runOnceErrorMap = new plugins.lik.FastMap();
constructor(optionsArg: IDomToolsContructorOptions) {
this.initializationPromise = this.initialize();
}
private runOncePromiseMap = new Map<string, Promise<unknown>>();
private async initialize() {
this.tryMarkDomReady();
if (this.domReady.status === 'pending') {
document.addEventListener('readystatechange', this.readyStateChangedFunc);
}
if (this.domToolsReady.status === 'pending') {
this.domToolsReady.resolve();
}
}
private tryMarkDomReady() {
if (this.disposed || this.domReady.status !== 'pending') {
return;
}
if (document.readyState !== 'interactive' && document.readyState !== 'complete') {
return;
}
if (!document.head || !document.body) {
return;
}
this.elements.headElement = document.head;
this.elements.bodyElement = document.body;
if (!this.keyboard) {
this.keyboard = new Keyboard(document.body);
}
document.removeEventListener('readystatechange', this.readyStateChangedFunc);
this.domReady.resolve();
}
private trackManagedDomNode<T extends Element>(elementArg: T): T {
this.managedDomNodes.push(elementArg);
return elementArg;
}
private getHeadOrBodyElement() {
const targetElement = this.elements.headElement || document.head || this.elements.bodyElement || document.body;
if (!targetElement) {
throw new Error('DomTools could not find a DOM target element to attach resources to');
}
return targetElement;
}
/**
* run a function once and always get the Promise of the first execution
@@ -135,34 +175,14 @@ export class DomTools {
* @param funcArg the actual func arg to run
*/
public async runOnce<T>(identifierArg: string, funcArg: () => Promise<T>) {
const runningId = `${identifierArg}+runningCheck`;
if (!this.runOnceTrackerStringMap.checkString(identifierArg)) {
this.runOnceTrackerStringMap.addString(identifierArg);
this.runOnceTrackerStringMap.addString(runningId);
try {
const result = await funcArg();
this.runOnceResultMap.addToMap(identifierArg, result);
} catch (error) {
// Store error so waiting callers can receive it
this.runOnceErrorMap.addToMap(identifierArg, error);
} finally {
// Always remove running flag to prevent permanent stuck state
this.runOnceTrackerStringMap.removeString(runningId);
}
let runOncePromise = this.runOncePromiseMap.get(identifierArg) as Promise<T> | undefined;
if (!runOncePromise) {
runOncePromise = Promise.resolve().then(async () => {
return await funcArg();
});
this.runOncePromiseMap.set(identifierArg, runOncePromise);
}
return await this.runOnceTrackerStringMap.registerUntilTrue(
(stringMap?: string[]) => {
return !stringMap?.includes(runningId);
},
() => {
// Check if there was an error and re-throw it
const error = this.runOnceErrorMap.getByKey(identifierArg);
if (error) {
throw error;
}
return this.runOnceResultMap.getByKey(identifierArg);
}
);
return await runOncePromise;
}
// setStuff
@@ -172,10 +192,10 @@ export class DomTools {
*/
public async setGlobalStyles(stylesText: string) {
await this.domReady.promise;
const styleElement = document.createElement('style');
const styleElement = this.trackManagedDomNode(document.createElement('style'));
styleElement.type = 'text/css';
styleElement.appendChild(document.createTextNode(stylesText));
this.elements.headElement!.appendChild(styleElement);
this.getHeadOrBodyElement().appendChild(styleElement);
}
/**
@@ -184,14 +204,16 @@ export class DomTools {
*/
public async setExternalScript(scriptLinkArg: string) {
await this.domReady.promise;
const done = plugins.smartpromise.defer();
const script = document.createElement('script');
const done = plugins.smartpromise.defer<void>();
const script = this.trackManagedDomNode(document.createElement('script'));
script.src = scriptLinkArg;
script.addEventListener('load', function () {
script.addEventListener('load', () => {
done.resolve();
});
const parentNode = document.head || document.body;
parentNode.append(script);
script.addEventListener('error', () => {
done.reject(new Error(`Failed to load external script: ${scriptLinkArg}`));
});
this.getHeadOrBodyElement().append(script);
await done.promise;
}
@@ -200,11 +222,20 @@ export class DomTools {
* @param cssLinkArg a url to an external stylesheet
*/
public async setExternalCss(cssLinkArg: string) {
const cssTag = document.createElement('link');
await this.domReady.promise;
const done = plugins.smartpromise.defer<void>();
const cssTag = this.trackManagedDomNode(document.createElement('link'));
cssTag.rel = 'stylesheet';
cssTag.crossOrigin = 'anonymous';
cssTag.href = cssLinkArg;
document.head.append(cssTag);
cssTag.addEventListener('load', () => {
done.resolve();
});
cssTag.addEventListener('error', () => {
done.reject(new Error(`Failed to load external stylesheet: ${cssLinkArg}`));
});
this.getHeadOrBodyElement().append(cssTag);
await done.promise;
}
/**
@@ -215,4 +246,28 @@ export class DomTools {
await this.websetup.setup(optionsArg);
await this.websetup.readyPromise;
}
public dispose() {
if (this.disposed) {
return;
}
this.disposed = true;
document.removeEventListener('readystatechange', this.readyStateChangedFunc);
this.keyboard?.dispose();
this.keyboard = null;
this.scroller.dispose();
this.themeManager.dispose();
for (const managedDomNode of this.managedDomNodes) {
managedDomNode.remove();
}
this.managedDomNodes.length = 0;
if (globalThis.deesDomTools === this) {
globalThis.deesDomTools = undefined;
DomTools.initializationPromise = null;
}
}
}