fix(domtools): stabilize DomTools lifecycle, cleanup, and singleton behavior
This commit is contained in:
+137
-82
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user