Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261// src/plugin/component.tsimport 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 trackingtype DomEventCleanup = { el: EventTarget; type: string; callback: EventListenerOrEventListenerObject; options?: boolean | AddEventListenerOptions }
// WeakMap to store loaded state, allowing external access without exposing in .d.tsconst componentLoadedState = new WeakMap<Component, boolean>()const componentWasLoadedState = new WeakMap<Component, boolean>()
/** * 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<void> 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<void>. 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<T extends Component>(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<T extends Component>(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<K extends keyof WindowEventMap>( 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<K extends keyof DocumentEventMap>( 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<K extends keyof HTMLElementEventMap>( 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 }}