// src/plugin/component.ts import type { EventRef, EventRefImpl } from './events' /** Tracks whether we've warned about addChild on a dead parent (deduped). */ let _warnedAddChildOnDead = false /** * Reset Component-level warn-once state for tests. * @internal */ export function _resetComponentWarnedState(): void { _warnedAddChildOnDead = false } // DomEventCleanup type for registerDomEvent internal tracking type DomEventCleanup = { el: EventTarget; type: string; callback: EventListenerOrEventListenerObject; options?: boolean | AddEventListenerOptions } // WeakMap to store loaded state, allowing external access without exposing in .d.ts const componentLoadedState = new WeakMap() const componentWasLoadedState = new WeakMap() /** * Check if a Component is currently loaded. * @internal - For Plugin and other internal subclasses only. */ export function isComponentLoaded(component: Component): boolean { return componentLoadedState.get(component) ?? false } /** @internal Component, Plugin and command teardown must drain before reporting failures. */ export function runCleanupActions(actions: readonly (() => void)[]): void { const errors: unknown[] = [] for (const action of actions) { try { action() } catch (error) { errors.push(error) } } if (errors.length === 1) throw errors[0] if (errors.length > 1) throw Object.assign(new Error('Cleanup failed'), { errors }) } /** * Base component class with lifecycle management. * Matches Obsidian's Component API. */ export class Component { // Use ES private fields (#) so they don't appear in the emitted .d.ts #children: Component[] = [] #cleanups: Array<() => any> = [] #eventRefs: EventRef[] = [] #intervals: number[] = [] #domEvents: DomEventCleanup[] = [] // Private accessors for internal WeakMap state get #loaded(): boolean { return componentLoadedState.get(this) ?? false } set #loaded(value: boolean) { componentLoadedState.set(this, value) } get #wasLoaded(): boolean { return componentWasLoadedState.get(this) ?? false } set #wasLoaded(value: boolean) { componentWasLoadedState.set(this, value) } /** * Load this component and its children. * * The upstream Obsidian contract declares `load(): void`, so we match that * for .d.ts emission. However, Reams' plugin loader depends on awaiting * async onload() results. At runtime we return the Promise when onload() * is async; consumers that don't care can ignore it (void-compatible), * but the plugin loader can `await` it. */ load(): void { if (this.#loaded) return this.#loaded = true this.#wasLoaded = true // onload() is typed as void for contract compliance, but subclasses // may return a Promise. Handle that gracefully at runtime. const result = this.onload() as void | Promise for (const child of this.#children) { child.load() } // Fire-and-forget async onload — install a catch fork to avoid unhandled rejection if (result instanceof Promise) { result.catch((err) => { console.error('Component onload() rejected:', err) }) // Return the promise so callers (like plugin loader) can await it. // The type says void but the runtime value is Promise. return result as unknown as void } } /** * Override this to perform initialization. */ onload(): void { // Override in subclass } /** * Unload this component and its children. */ unload(): void { if (!this.#loaded) return this.#loaded = false runCleanupActions([ ...this.#children.splice(0).map(child => () => child.unload()), ...this.#cleanups.splice(0), ...this.#eventRefs.splice(0).map(ref => () => (ref as EventRefImpl).e.offref(ref)), ...this.#intervals.splice(0).map(id => () => clearInterval(id)), ...this.#domEvents.splice(0).map(({ el, type, callback, options }) => () => el.removeEventListener(type, callback, options)), () => this.onunload(), ]) } /** * Override this to perform cleanup. */ onunload(): void { // Override in subclass } /** * Add a child component. Loads it if this component is loaded. * If this component was previously loaded then unloaded (dead), the child * is unloaded to prevent orphans and NOT added to children. A warning * is emitted ONCE per session in this case because callers expect the child * to be tracked, and silently dropping it tends to look like a working render. * If this component was never loaded, the child is stored for deferred loading. */ addChild(component: T): T { if (!this.#loaded) { if (this.#wasLoaded) { // Dead component (loaded then unloaded) — drop the child to prevent // orphans. Emit a one-time warning so the developer notices the trap // (without flooding the console during async race conditions). // component.unload() is safe — it's a no-op if not loaded. component.unload() if (!_warnedAddChildOnDead) { _warnedAddChildOnDead = true console.warn( '[Reams] Component.addChild called on a dead (unloaded) parent. ' + 'The child was unloaded and dropped; it will not be tracked. ' + 'This usually means a render context was used after its owner unloaded. ' + '(This warning is shown once per session.)' ) } return component } // Not yet loaded — store child for deferred loading when parent loads this.#children.push(component) return component } this.#children.push(component) component.load() return component } /** * Remove a child component. Unloads it. */ removeChild(component: T): T { const index = this.#children.indexOf(component) if (index !== -1) { this.#children.splice(index, 1) component.unload() } return component } /** * Register a cleanup callback to run on unload. */ register(cb: () => any): void { if (!this.#loaded && this.#wasLoaded) { cb() return } this.#cleanups.push(cb) } /** * Register an event to be detached on unload. * If this component is dead (loaded then unloaded), immediately detaches the ref. * If not yet loaded, stores the ref for cleanup when the component eventually unloads. */ registerEvent(eventRef: EventRef): void { if (!this.#loaded && this.#wasLoaded) { // Dead component — immediately detach to prevent listener leak // Cast to internal type to access implementation details const impl = eventRef as EventRefImpl impl.e.offref(eventRef) return } this.#eventRefs.push(eventRef) } /** * Registers a DOM event to be detached when unloading. * This overload is for Window events. */ registerDomEvent( el: Window, type: K, callback: (this: HTMLElement, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions ): void /** * Registers a DOM event to be detached when unloading. * This overload is for Document events. */ registerDomEvent( el: Document, type: K, callback: (this: HTMLElement, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions ): void /** * Registers a DOM event to be detached when unloading. * This overload is for HTMLElement events. */ registerDomEvent( el: HTMLElement, type: K, callback: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions ): void registerDomEvent( el: EventTarget, type: string, callback: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions ): void { if (!this.#loaded && this.#wasLoaded) { // Dead component — don't add, user already missed the window return } el.addEventListener(type, callback, options) this.#domEvents.push({ el, type, callback, options }) } /** * Register an interval to be cleared on unload. * Use window.setInterval, not setInterval (to avoid Node.js types). * If this component is dead (loaded then unloaded), immediately clears the interval. * If not yet loaded, stores the interval for cleanup when the component eventually unloads. */ registerInterval(id: number): number { if (!this.#loaded && this.#wasLoaded) { // Dead component — immediately clear to prevent interval leak clearInterval(id) return id } this.#intervals.push(id) return id } }