There are two design flaws in the API method, setActiveLeaf()
, that are causing virtually every plugin that in any way deals with the currently active leaf (either by responding to activation, causing it, or just interacting with the active leaf during a click event!) to have to include kludges and workarounds (including the use of setTimeout()
!) to try to get things to work properly.
First off, as I’ve previously mentioned on certain bug reports, setActiveLeaf()
only sets the workspace .activeLeaf
property and sets the .mod-active
class on the leaf’s DOM node.
This means that any plugin that wishes to really activate a leaf must re-implement the same code as the various editor:focus-*
commands, and hope to get it right. For this reason, either setActiveLeaf()
needs to perform all the actions needed to activate the leaf (including focusing the codemirror or preview scroll region – either directly or by delegation to the view), OR there needs to be an API call that can be used to do this.
The second cause of plugins needing to add workarounds is the file-open
event. This event is triggered synchronously in setActiveLeaf()
, which sets things up for unintended re-entrance on the call stack and unpredictable behavior depending on what part of Obsidian called setActiveLeaf()
in the first place.
This is why more than one plugin is using setTimeout()
: in order to process the event without conflicts, they need to wait until after the current event loop turn is over, so that whatever was doing the setActiveLeaf()
can be finished. The duration of the timeout is of course a guess, as there is no way to know a priori what it should be.
Unfortunately, with more than one plugin hooking this event with its own guesses of when to trigger, and more than one plugin invoking setActiveLeaf()
and doing its own focusing workarounds, this is creating a major pileup wherein the behavior of one plugin is very much dependent on what other plugins are installed… and also fairly non-deterministic due to timing issues.
This issue is further compounded by what happens if setActiveLeaf()
is called multiple times in the same event turn, with different leaves. A file-open
event will be triggered for each one, and then the workaround setTimeout()
s will be triggered twice, possibly out-of-order, and possibly interleaved with those of other plugins in unpredictable order!
This entire set of problems could be avoided by simply triggering the file-open
event asynchronously, using e.g. process.nextTick()
. This would eliminate the need for plugins to use setTimeout()
, as they could simply perform their desired action and know that the UI had already been fully updated beforehand, and that they would be executing in an orderly fashion, with no non-deterministic behavior.
Of course, there would still be the issue that if there is more than one setActiveLeaf()
call in the same event turn, then plugins could process a file-open
event for a leaf that is no longer active. (This condition can also happen now, since by the time a setTimeout()
happens, the active leaf could have changed again!)
This could be fixed by having the async trigger fire once, for the active leaf as of the end-of-turn. The downside to this is that the implied semantics of a file-open
event are that you should receive it when files are opened, and if that’s really what someone is trying to trap, they might want to receive every instance rather than just the most recent.
But, since the file-open
event is also the only way to detect a change in the active pane, there isn’t a way to make a clean distinction without introducing a separate event. (Maybe active-leaf-changed
?)
So, yeah. I know I’ve spent hours (today alone) trying to figure out why the heck my plugin wasn’t working, tracking down what other plugins were doing, enabling and disabling things, wading through kludges on top of other kludges in the debugger just so I could figure out a meta-kludge to work around all the other kludges. Not fun.
Especially since my plugin wasn’t even trying to set the focus or be triggered by it! It was just catching a mouse-down in a codemirror and invoking an editor command. But if you happened to click in a non-active pane, things went haywire because of the order in which setActiveLeaf()
was being called relative to when the command executed. (And that was just the tip of the iceberg of problems involved.)
I was finally able to work around the issue by literally capturing the mouse event and then telling codemirror to pretend it received the event directly, just so that all the active-leaf-setting fallout would happen before my method ran, instead of after, and I wouldn’t have to guess at a setTimeout()
interval that’s somewhat dependent on what other plugins are active.
This problem with the file-open
event is also representative of a problem with Obsidian events in general: they are synchronous even when they don’t need to be.
The only time an application event should be triggered synchronously is when it’s important for event handlers to be able to cancel the operation in progress, contribute data or options to that operation, or otherwise interfere with it somehow.
For example, if a menu was going to pop up, and it wanted to give plugins a chance to add dynamic, context-specific menu items, then a synchronous event that’s passed a menu object as an argument makes perfect sense: event handlers can then just add their items, and they’re there for the actual rendering.
But if an event exists merely to notify plugins that “hey, I did this thing y’all”, then it should be triggered asynchronously, as otherwise the kinds of problems we’re seeing with file-open
are guaranteed to happen eventually.
In the specific instance of file-open
, the problem is that setActiveLeaf()
does not really know whether the current operation is “finished”, so trying to tell plugins that, “hey, this thing is done” is premature. In the case of the editor:focus-*
commands, for example, the operation is definitely not finished yet, so the event is fired in the middle of the operation. Using process.nextTick()
to trigger “notification” events (vs “participation” events) alleviates this problem by waiting until the current UI-level event is over before notifying plugins that it’s over.
One way to think of this, is as a kind of ask vs. tell or command/query separation. If the purpose of an event is to ask plugins something, then it should be synchronous, as the triggering operation needs to get an answer before it can proceed. But if the purpose is just to tell plugins that something happened, then it can (and should) wait until the entire current operation is completed, using process.nextTick()
or the like.