Stimulus Discourse

Waiting for controllers (or controller interaction)

Hi.

Firstly, the topic name might not be fully descriptive and I’m open for suggestions.

The overall setup
Within a website’s head I am loading an external third-party JavaScript resource via a script element and triggering a Stimulus controller on that same script element. Let’s call that controller for HeadController. This controller does some async stuff such as calling external HTTP based APIs, interacting with other preloaded third-party scripts/objects and manipulating some global state. All this is initiated within HeadController.initialize() and happens through a promise then chain.
The site’s body contains several elements which each have another Stimulus controller attached to them, though the number is irrelevant with regards to the underlying problem. In this case it’s instances of the same controller class, though this should be irrelevant as well. Let’s call this controller DoStuffController.

The problem:
DoStuffController.connect() needs to, well, do stuff, but depends on a fully completed flow within HeadController.initialize(). With the current setup, DoStuffController.initialise() and DoStuffController.connect() are sometimes called before the actions within HeadController.initialize() are done and thus before HeadController.initialize() is done.

Somehow halting the flow until HeadController's initialize() is done is not an option.

I’m wondering how you would go about solving this issue. As I gather from the documentation there is no built-in way to neither monitor the current state of a Stimulus controller, nor to create flow related dependencies between them, nor to trigger/monitor one controller from another and as such no built-in way to achieve what I need? Correct me if I am wrong.
I did have a quick read through issue #35

Attempt a a solution:
I am sure there is a number of patterns which could be utilized to solved this such as:

A) The Observer pattern could be used although

  1. this would couple the individual controllers slightly more tight than one might want
  2. subscriptions taking place after subject’s completion will have no effect as there is neither history nor state and no way to monitor it if there were.

B) The Publisher/subscriber pattern solves caveat 1 of the Observer pattern but not caveat 2.

There might be (combinations of) other patterns which could be used that I am not aware of but as a stopgap solution I went with a bastardized version of the Observer with internal state tracking within the subject. This way I can trigger a notify on the subscriber if it subscribes after flow completion. I’m am using this.application.getControllerForElementAndIdentifier(..) from within the subscriber which is part of the coupling related problem.
Neither approach nor solution is close to being elegant and I will more than likely regret it later on as the application grows.

I would very much like to hear your thoughts on this as well as and alternative solutions.

Thanks.

One solution would be leveraging DOM events (which I’m generally a big fan of for cross-controller communication) and also introduce a request event fired by the DoStuffController in case it’s late to the game.

The HeadController would emit an event indicating that it has completed it’s workflow, e.g.

// HeadController.js
let event = new CustomEvent('pageInitialized', {detail: {additionalData: 'foo'} });
this.element.dispatchEvent(event);

DoStuffController will hear this unless HeadController finishes before DoStuffController has even initialized. DoStuffController, not knowing whether it’s late to the party, will always issue a request for the state, e.g.

// DoStuffController.js
initialize() {
  window.addEventListener('pageInitialized', this.handlePageInitialization, true);
  let event = new CustomEvent('pageInitializedQuery');
  this.element.dispatchEvent(event);
}

HeadController has already registered to listen for the pageInitializedQuery event and upon hearing it fires off the pageInitialized event if it’s ready to do so. Otherwise, it does nothing until it’s done doing its thing, at which point it fires off the pageInitialized event as per usual.

One thing to note is that dispatching events in this way is synchronous, unlike typical browser-generated events which leverage the event loop and are asynchronous. That means that by the time this.element.dispatchEvent(event) returns in DoStuffController, HeadController will have already dispatched its own event (if ready) and called DoStuffControllers handler for pageInitialized.

This should alleviate both the coupling and timing concerns. One gotcha is to make sure any listeners of HeadController handle its events with idempotency, as it’s possible they’ll hear the pageInitialized event twice. That could be done by keeping state within DoStuffController or even by removing the pageIntitialized event listener in DoStuffController after it’s triggered for the first time.

Hope this helps!