Returning messages from a Halogen component to the caller (JavaScript)

Problem

I need some way to enable some JavaScript process to consume the stream of messages output by a Halogen component. In other words, the ‘parent’ of the component isn’t yet another Halogen component, it’s an executing script running in the browser, which might be a React component or something similar.

A WIP solution is here:
thomashoneyman/purescript-halogen-leaf

Background

At my company we’ve largely replaced a large Angular application with Halogen.

At first, my team mounted Halogen components within otherwise Angular-controlled pages. The components didn’t communicate with Angular except through shared resources (local storage, the API, etc). As far as the Javascript was concerned there was just some chunk of code executed and left to its own devices.

Now, though, other teams within our parent company would like to use the various Halogen components we’ve built. Since they won’t be inside a larger Halogen application, we really do have to communicate from the Halogen component to some framework-agnostic consumer in JavaScript.

Attempted Solution

A Halogen component, once run with something like runUI, produces the HalogenIO type:

type HalogenIO f o m =
  { query :: f ~> m
  , subscribe :: Consumer o m Unit -> m Unit
  }

query enables some external source to trigger queries in the root component, and subscribe allows some external consumer to receive messages from the root component.

Exactly what we want! All I have to do is construct some Consumer o m Unit, which can be done by providing the consumer function from purescript-coroutines with a function (o -> m (Maybe Unit)). Then, every time some output is produced by the root, the handler will run, and we’re good to go.

Our app main function might look like this:

main
  :: ∀ f i o
   . H.Component HH.HTML f i o Aff
  -> i
  -> String
  -> (o -> Aff (Maybe Unit))
  -> Effect Unit
main component input querySelector = HA.runHalogenAff do
  element <- HA.selectElement (QuerySelector querySelector)
  io <- traverse (runUI component input) element
  traverse_ (\i -> i.subscribe $ consumer handler) io
  pure unit

Next, I turned to the JavaScript to see what that might look like. While I couldn’t quite figure out how to provide that o -> Aff (Maybe Unit) function, this seems like a nice minimal bit of vanilla JS to play around with for testing:

import { main } from './halogen/output/Main/index.js';
import { component } from './halogen/output/Component/index.js';

// Create a div to hold the Halogen component and mount the
// component with its arguments.
function mkComponent(args) {
  let element = document.createElement('div');
  let querySelector = "#" + args.id;

  element.setAttribute("id", args.id);
  document.body.appendChild(element);

  // Mount the Halogen component
  main(args.component)(args.input)(querySelector)();
}

mkComponent({component, input: {initialText: "hello"}, id: "component"});

Problems

Unfortunately, this attempt has some problems already:

  1. I don’t actually know how to produce the function o -> Aff (Maybe Unit) and pass it in. It’s the responsibility of the folks using the component to write it, so we can’t write it on their behalf. And with #2, perhaps this isn’t even a reasonable approach.

  2. We’ve lost the ability to query the component, which I’m sure will later become important. It seems like a better approach would be just to return the { subscribe, query } record and allow the caller to leverage either function as they see fit (call subscribe with some JavaScript promise or something to consume values, and call query to trigger queries in the component).

So far I haven’t been able to get either #1 or #2 to work, but I’m continuing to push on #2 as the more promising approach.

If this sparks any ideas among y’all, please share! Otherwise, I’ll update when I have more to say about this.

API Design

[excerpted from Slack]

The simplest API design that I think would be usable as a starting point would be a single function, mount, which takes a component, the component’s input, a query selector at which to mount the component, and some sort of handler which can process the outputs. Then, the work to actually call this handler on every output is done within Halogen and it’s a bit simpler to implement.

A more complex but perhaps nicer one might be to instantiate an object representing the component, where you can call the query() method to query the component and the subscribe() method to subscribe to updates (still not sure how to do this).

If I were trying to consume a component from JS, I would not want to assume anything about the internals of Halogen or PureScript representations. I would take care to translate to and from the different worlds. For things like outputs, I’d probably use Variant, since it’s easy to discriminate in JS, and for things like queries I would probably use purescript-aff-promise. In my mind that would look something like:

data Query a
  = Set String a
  | Get (String -> a)

data Output
  = DidChange String

-- A translation of Output to Variant.
type OutputVariant = Variant
  ( didChange :: String
  )

-- A listener which takes a callback, and returns an effect to unsubscribe.
-- An effect to unmount the component.
-- Everything is EffectFn so JS users don't have to remember to invoke the effect.
type WithHalogen out r =
  ( subscribe :: EffectFn1 (EffectFn1 out Unit) (Effect Unit)
  , dispose :: Effect Unit
  | r
  )

-- Methods for each public query, returning a Promise from purescript-aff-promise.
type QueryMethods =
  ( set :: EffectFn1 String (Promise Unit)
  , get :: Effect (Promise String)
  | r
  )

type HalogenInterface out queries =
  { | WithHalogen out + queries }

type MyInterface =
  HalogenInterface OutputVariant QueryMethods

-- Let the user specify the HTML element instead of a selector.
-- Initialization is technically async, but we want to return an interface
-- synchronously, so we need to buffer with an avar.
mount :: EffectFn1 HTMLElement MyInterface
mount = mkEffectFn1 \el -> do
  ioVar <- AVar.empty -- AVar has an Effect interface as well as an Aff interface
  launchAff_ do -- Fork the Halogen process
    io <- runUI ... -- Run your ui with the given element
    AffAVar.put ui ioVar -- Put the HalogenIO record in the avar

  -- Build up methods for your interface. Some of this can likely be pulled
  -- out into a generic implementation.
  let
    subscribe = mkEffectFn1 \cb -> do
      fiber <- launchAff do -- Fork a fiber to run a consumer
        io <- AffAvar.read ioVar
        io.subscribe $ consumer $ do
          out <- await
          let outVariant = convertToOutputVariant out
          liftEffect $ runEffectFn1 cb outVariant
          pure (Just unit)
      -- Return an effect to unsubscribe by killing the consumer
      pure $ launchAff_ $ killFiber (error "unsubscribed") fiber

    dispose =
      ... -- Needs some special stuff on v5

    set = mkEffectFn1 \value -> Promise.fromAff do
      io <- AffAVar.read ioVar
      io.query (Set value unit)

    get = Promise.fromAff do
      io <- AffAVar.read ioVar
      io.query (Get identity)

  -- Return a record of the interface
  pure { subscribe, dispose, get, set }

Then from JS, this could be consumed in an idiomatic way:

const component = mount(myElement);
const unsubscribe = component.subscribe((out) => {
  switch (out.type) {
    case "didChange":
      console.log("Something changed", out.value);
      break;
  }
});

component.set("Hello").then(() => {
    console.log("The value was set");
});

component.get().then((value) => {
  console.log("This is the current value", value);
});

component.dispose();

Some of that is a little hand-wavy, but hopefully it helps.

1 Like

@natefaubion you are a godsend. I’ve implemented a working version of this and am currently bundling up a fairly complex component. I’ll respond later with a fuller description of how it all fits together in case others will benefit from the details. Thanks!

As an addendum, I’d probably recommend you encode/decode output/parameters as JSON for better invariants.

I’ve been considering the best way to deal with more complex data that needs to be passed back and forth, and JSON seems like a good way to do that. I certainly don’t want to force the caller to have to construct PureScript types to pass in.