RmdirGuardComponent
Makes app.vault.adapter.rmdir(path, false) behave as its name promises: a non-recursive call on a folder that still has children throws instead of deleting them.
Obsidian’s adapters do not honor
recursive: false, and they get it wrong in opposite directions:
CapacitorAdapter(mobile) does not accept the argument at all — it always removes recursively, so a non-recursive call silently deletes the folder and everything under it. This is the data-loss footgun. -FileSystemAdapter(desktop) forwards it tofs.promises.rm(path, { recursive }), which throwsERR_FS_EISDIRfor any directory whenrecursiveisfalse— so the non-recursive call never succeeds, not even on an empty folder.While loaded, this component gives both platforms the same, correct behavior: a non-empty folder is refused with an NOT_EMPTY_DIRECTORY_ERROR_CODE error and nothing is deleted; an empty folder is removed. Emptiness is decided from the adapter (via isEmptyFolder), not from the vault index — the index omits dot-prefixed and otherwise hidden entries, so a folder holding only hidden files reads as empty there and the guard would wave through exactly the deletion it exists to prevent.
A folder proven empty is removed by forwarding to the original method with
recursive: true. That is not merely defensive: it is what makes the empty-folder case succeed on desktop, where the non-recursive call would otherwise throw.Calls that already pass
recursive: true, and calls whose target is not a folder (a file, a missing path), are passed through untouched and keep their native behavior.Loading the guard more than once is safe, and it deliberately carries no patch token. Every plugin bundles its own copy of this library and they all patch the one shared adapter, so several guards do stack — but the operation is idempotent (prove empty, then delete), so stacking changes nothing: the outermost guard either throws or forwards with
recursive: true, which every guard beneath passes straight through. They can also be unloaded in any order and the method stays guarded until the last one goes, becausemonkey-aroundneutralizes an unloaded wrapper in place rather than splicing it out. A token would only move the decision to the innermost guard — an equally arbitrary choice — while adding a path on which a guard declines to guard. For a data-loss guard that trade is not worth making. ContrastRenameDeleteHandlerComponent, which does need one: suppressingrunAsyncLinkUpdateis not idempotent, so two of those would conflict.This component is deliberately opt-in:
app.vault.adapteris shared by the whole app, so a library that installed this patch on its own would changermdirsemantics for every other plugin, including the ones that never asked for it. A plugin that deletes folders opts in for itself:
Import:
import { RmdirGuardComponent } from 'obsidian-dev-utils/obsidian/components/rmdir-guard-component';Example:
this.addChild(new RmdirGuardComponent(this.app));Signature:
export class RmdirGuardComponent extends MonkeyAroundComponentExtends: MonkeyAroundComponent
Constructor
new RmdirGuardComponent(app: App)Creates a new instance of the RmdirGuardComponent class.
Methods
| Method | Returns | Description |
|---|---|---|
| [Symbol.dispose]() | void | Disposes of the component. (Inherited from MonkeyAroundComponent) |
| addChild(component) | TComponent | Adds a child component. Mirrors the native Component.addChild contract: if this component is already loaded, the child is loaded immediately, so child._loaded is set before this method returns even when this component has async load logic. The child's async tail (if any) is sequenced into the load promise so a later loadWithPromises call awaits it.Adding a child BEFORE the first load is legitimate: the child is queued and loaded when this component loads. Adding one AFTER this component has been unloaded is not — the child would never be loaded and never unloaded (a leak), so it is refused with a SilentError. The typical source of such a call is an async method that was suspended on an await when the component got unloaded and then resumed on the dead component; the SilentError unwinds it quietly (see isUnloaded).(Inherited from MonkeyAroundComponent) |
| ensureLoaded() | void | Ensures the component is loaded, throwing if it is not. Use this to guard public methods that register teardown-bearing resources (via Component.register, Component.registerEvent, Component.registerDomEvent, etc.). Registering before load is unsafe: Component.unload is a no-op while the component is not loaded, so any teardown registered beforehand would never run if the component is unloaded without first being loaded.The two not-loaded cases are deliberately distinguished. A component that was NEVER loaded is a genuine programming error, so it throws a loud Error. A component that has ALREADY been unloaded is not — it is the expected outcome of work that outlived its component (e.g. an async method suspended on an await while the component was unloaded, then resumed) — so it throws a SilentError, which handleSilentError suppresses, letting such work unwind quietly instead of reporting an async error.(Inherited from MonkeyAroundComponent) |
| getInFlightLoadPromise() | null | Promise<void> | Returns the component's in-flight load Promise (its onloadAsync plus children), or null when nothing is loading — either the component loaded fully synchronously or its async load has already settled.Lets a caller scheduled while the component is still loading await the async load tail before acting, instead of racing it. The motivating case is a layout-ready handler (see LayoutReadyComponent) that fires because the component was loaded after the workspace layout was already ready: Component.onload has run (so _loaded is set) but onloadAsync may not have finished.(Inherited from MonkeyAroundComponent) |
| hasLoadErrors() | boolean | Returns whether the most recent load recorded any failure. Reflects the outcome of the last load / loadWithPromises: true when onloadAsync or any child's load threw (synchronously or asynchronously). Unlike loadWithPromises, reading this does NOT re-raise the collected errors — it lets a bystander (e.g. a layout-ready handler awaiting the in-flight load) tell whether the load succeeded without adopting a failure that the load's owner is already responsible for.(Inherited from MonkeyAroundComponent) |
| isUnloaded() | boolean | Returns whether the component has already been unloaded, as opposed to simply not having been loaded yet. Both states leave _loaded false, but they mean opposite things: a component that was never loaded is still ahead of its lifecycle (queueing children before load is legitimate), while an unloaded one is behind it and must not be used any further. The distinction is tracked by a flag set in load, NOT by an Component.onunload override, because subclasses override onunload and may not call super.Use it in a long-running async method to abandon work whose component was unloaded mid-flight, when unwinding via the SilentError thrown by ensureLoaded / addChild is not desired.(Inherited from MonkeyAroundComponent) |
| load() | void | Loads the component. (Inherited from MonkeyAroundComponent) |
| loadWithPromises() | null | Promise<void> | Loads the component with promises. Unlike load, this method never rejects with an individual error: every failure raised by onloadAsync or a child's load is collected, and once everything settles the returned Promise rejects with a single AggregateError holding all of them. Non-Error throwables are normalized via ErrorWrapper.create.(Inherited from MonkeyAroundComponent) |
| onload() | void | Installs the rmdir guard. The patch is uninstalled when this component unloads. |
| onloadAsync() | Promise<void> | Asynchronously loads the component. Override to add async load logic, which is executed after Component.onload.(Inherited from MonkeyAroundComponent) |
| registerDisposable(disposable) | TDisposable | Registers a Disposable so it is disposed when this component unloads, and returns it unchanged.The recurring "tie a disposable to the component's lifecycle, then keep using it" idiom: the disposable is disposed on Component.unload (or earlier, if the caller disposes it directly — dispose is expected to be idempotent). Guard with ensureLoaded at the call site when registering before load would be unsafe.(Inherited from MonkeyAroundComponent) |
| registerFunctionPatch(params) | void | Registers a patch for a single function-like member (a method, or a callable such as a Debouncer) using a simplified handler.(Inherited from MonkeyAroundComponent) |
| registerMethodPatch(params) | void | Registers a single-method patch using a simplified handler. (Inherited from MonkeyAroundComponent) |
| registerPatch($object, factories) | void | Registers a patch using raw factories (advanced API). (Inherited from MonkeyAroundComponent) |
| removeChild(component) | TComponent | Removes a child component. (Inherited from MonkeyAroundComponent) |