Idea

Compile-time DOM bindings

When one value on a page changes, something has to decide which parts of the page to update. Which elements show this value? Does a click rerender the whole page, or only the text that changed? React, for example, reruns components and compares the result. Here we follow a counter’s write effect from Bosatsu source through compilation and the browser runtime to a changed DOM element. The generated counter runs the real program; the controls below illustrate the stages.

Credit: updating only the elements that depend on a changed value, worked out when the program is compiled, is the approach of Svelte and Solid. Yichus derives those bindings from the compiled Bosatsu program.

Code & run Read and run the counter

Illustrated Counter Controls

Click a button to highlight the runtime stages described below

0
Clicks: 0
Re-renders: 0

Takeaway: this page-level widget illustrates the sequence, but it does not execute generated Yichus code and cannot prove runtime behavior. Open the generated counter for the real execution path, and see its interaction test for repeated updates and binding-map diagnostics.

This simplified counter uses back-arrow syntax to sequence its IO operations. Constructing an IO value does not mutate state. write returns IO[Unit], an inert value that describes a mutation without performing it. (Reading the notation: x <- e.flat_map() is a do-block, meaning “run e, bind its result to x”. pure lifts a plain value into IO.) The handler returns an effect for runtime dispatch. Read the complete counter source.

package Demo/Counter

from Bosatsu/Predef import add, Int, Unit, int_to_String
from Yichus/IO import IO, pure, flat_map
from Yichus/UI import VNode, State, state, read, write, h, text, on_click

count: IO[State[Int]] = state(0)

def increment() -> IO[Unit]:
  c <- count.flat_map()
  current <- c.read().flat_map()
  c.write(current.add(1))

def render_counter() -> IO[VNode]:
  c <- count.flat_map()
  current <- c.read().flat_map()
  pure(h("div", [("class", "card")], [
    h("span", [("id", "count-display")], [text(int_to_String(current))]),
    h("button", [on_click(increment())], [text("+")])
  ]))

main: IO[VNode] = render_counter()
write has signature (State[a], a) → IO[Unit]. Calling it constructs an IO value, a recipe for a future mutation. Nothing happens yet.

React’s setter requests an update; React can batch requests and skip rendering for unchanged state. See the setter reference. In this Yichus counter, constructing the write IO does not mutate state; runtime interpretation performs the write.

On the JVM (Scala) side, write constructs a deferred FlatMap node. This path is used during evaluation and testing. Takeaway: constructing the external does not mutate the cell; the JVM external shows the boundary.

// From UI.scala — JVM external for write
private def deferredUiMutation(effect: => Unit): Value =
  ExternalValue(
    YichusIO.FlatMap(YichusIO.Pure(UnitValue), (_: Value) => {
      effect
      YichusIO.Pure(UnitValue)
    })
  )

The mutation closure is captured but not called until runWithProvenance interprets the FlatMap chain. A test in UITest.scala proves this:

val writeIo = call2("write", stateVal, Str("after"))
assertEquals(state.value, Str("before"))   // still "before"

val result = runIO(writeIo)                // interpret the IO
assertEquals(state.value, Str("after"))    // NOW it changed
The test "write mutation is deferred until IO is run" proves no mutation occurs before the IO runtime executes the chain. It does not test DOM routing or prove that a render was avoided. Those contracts are covered separately in UIGenTest.scala.

UIAnalyzer walks the TypedExpr AST of main before generating any JavaScript. It sees:

  1. state(0): allocates state, assigns it an internal id like "count"
  2. read(c): records that "count" is read during render
  3. text(int_to_String(current)) inside h("span", ...): the state read flows into a text node inside a <span>

From this it produces a DOMBinding. The record identifies the state, target element, and property to update.

This binding is serialized into JavaScript and embedded in the generated HTML as a literal object. Takeaway: the update target is resolved at compile time. The exact extraction and serialization live in UIAnalyzer.scala:

const _bindings = {
  "count": [{
    elementId: "count-display",
    property:  "textContent",
    when:      null,
    transform: "_int_to_String"
  }]
};
UIAnalyzer builds this map at compile time. The generated runtime consumes the embedded map.

For this counter, Yichus resolves the target element, property, and transformation at compile time. The runtime can follow that binding directly. The React comparison explores the performance question, but measures update-enqueue throughput rather than completed renders.

JsGen compiles IO operations into JavaScript thunks that return { value, trace } objects. The snippets in this section explain the shape and are not promised as byte-verbatim current generator output. Takeaway: event handlers still return IO, and runtime dispatch executes it. Follow the current implementation in JsGen.scala and reactive.js:

BosatsuGenerated JS
pure(x)() => ({ value: x, trace: [] })
flatMap(io, f)IO composition: execute io, then execute the IO returned by f
read(c)() => ({ value: c.value, trace: [] })
write(c, v)_ui_write_io(c, v), which returns a thunk

State allocation. state(0) compiles to a memoized thunk that creates or reuses a state object:

var count = (() => {
  let _state_ref = null;
  return () => {
    _state_ref = _state_ref || _ui_create_state(0);
    return { value: _state_ref, trace: [] };
  };
})();

_ui_create_state produces { id: "count", value: 0 }.

Event handler registration. on_click(increment) compiles to a prop tuple that stores the handler in a global table:

["data-onclick", _ui_register_handler("click", increment)]

_ui_register_handler stores the handler function under an id like "h_0" and returns that id. The VNode ends up with data-onclick="h_0" in its props.

The compiled increment handler. The entire function becomes a chain of IO thunks:

var increment = (_) => {
  // Returns an IO (a thunk). Nothing runs yet.
  return () => {
    const _a = count();                   // IO[State[Int]] → state obj
    const _b = (() => {
      const _a2 = (() => ({               // read(c) → IO thunk
        value: _a.value.value,            // reads stateObj.value
        trace: []
      }))();
      const _b2 = _ui_write_io(           // write(c, current + 1)
        _a.value,                          // the state object
        _a2.value + 1                      // new value
      )();                                 // ← invoked: effect runs
      return { value: _b2.value,
        trace: _a2.trace.concat(_b2.trace) };
    })();
    return { value: _b.value,
      trace: _a.trace.concat(_b.trace) };
  };
};
The outermost arrow returns a thunk. Nothing happens until something calls that thunk. The _ui_write_io call inside is only reached when the IO interpreter runs the chain.

The counter’s generated handler returns an IO representation. Its dispatch path executes that IO explicitly. This describes the Yichus example; JavaScript event handlers can themselves use many effect abstractions.

When the VNode tree is rendered to real DOM, event props are wired to addEventListener. Takeaway: the listener calls the handler and passes the returned IO to _ui_run_io. See the event branch in reactive.js:

_ui_run_io is the IO interpreter. It calls the thunk:

var _ui_run_io = (io) => {
  if (typeof io === 'function') return io();
  return io;
};

This Yichus runtime wires a native listener to the generated handler and executes its returned IO. That wiring alone does not establish a performance advantage over another framework.

The base _ui_write_io from JsGen returns a thunk that calls _ui_write:

var _ui_write_io = (state, value) => () => {
  _ui_write(state, value);
  return { value: [], trace: [] };
};

UIGen embeds the shared reactive runtime, which overrides _ui_write to route through the binding map. The actual path checks for a direct binding, canvas binding, tracked render read, or declared rerender route before changing state. A state with no route increments bindingMapMisses and throws instead of silently rerendering. Takeaway: scalar bindings queue direct updates, while missing routes fail loudly. Read the current runtime source and the routing tests:

For the generated counter, _requiresRerender returns false, and the scalar text route reaches _queuePathUpdate through the root-binding queue. The generated-counter E2E checks repeated updates and reports a failure if window._yichus_diagnostics.bindingMapMisses is nonzero.

React distinguishes rendering components from committing DOM changes; a render need not change the DOM. See React’s render and commit guide. The Yichus counter instead follows its compiled scalar binding when the queued write is flushed.

On the next microtask, _flushPendingUpdates calls _updateBindings for each queued state id. The function throws on a missing binding and supports several property types. Takeaway: a valid scalar binding resolves the target element and writes the named property. Read the exact runtime function:

There is no virtual DOM diff here and no render function call, just one el.textContent = "1" assignment. The binding map told the runtime exactly which element and property to touch.

This direct binding path avoids a render-and-diff pass for the counter’s scalar update. It still executes the handler, updates state, queues work, and formats the value. Other Yichus update routes can rebuild structure; the optimization is specific to the compiled binding.

Counter Click Execution Sequence

Takeaway: the generated counter crosses one explicit IO execution boundary and then uses its compiled binding route. The sequence below is grounded in the generated demo and runtime source:

  1. Starting at zero with step size one, click +1.
  2. The native click listener executes the registered increment IO with _ui_run_io.
  3. apply_step(Up) reads the count and step size, adds the step, and clamps the result to the declared bounds.
  4. The write IO updates the count through its validated binding route and queues the changed state.
  5. The runtime flush resolves the count’s count-display.textContent binding and formats the integer as text.
  6. The displayed count becomes 1. The direct update does not rebuild the application.

Full-Render Routing Conditions

_requestRender() calls _renderApp() and rebuilds the VNode tree for update routes that require it. The generated runtime covers conditional discriminants, computed or composite properties, and tracked render reads without a direct or canvas binding. Takeaway: direct scalar bindings avoid this path, while other declared routes can rebuild. See UIGen.scala for compile-time route validation and reactive.js for dispatch:

  1. Render control: a state selects a render-time branch or guard, including a subtree containing only literal content. Its render-control-dependency remains visible even when a direct scalar mirror also reads that state.
  2. Properties and conditional bindings: several states feed one property, a computed property has no available transform, or a binding's element exists only inside a refutable branch.

window._yichus_diagnostics.forcedRerenderReasons exposes the generator's forced-root decisions. It does not enumerate every conditional, canvas or list update route. Several elements may share direct scalar binding-map updates; hidden duplicate bindings or trigger-only state are unnecessary for redraws. Express the actual state dependency in the view.

The generated counter’s scalar update follows a direct binding rather than re-calling its render function. Other writes in a larger application may still require rendering.

A true result from the runtime rerender predicate selects the full-render route for the state shapes listed here.

Honest scope: a rebuild reconstructs the VNode tree with no diffing. This page does not measure the cost of that rebuild. The binding-map story shown here is the scalar case. How often real applications require rebuilding is not measured on this page.

TypedExpr UI Analysis and Matchless Explorer Analysis

Step 3 connects to the rest of the tooling, with an important boundary. UIAnalyzer consumes Bosatsu TypedExpr to build DOM bindings. The Explorer fact tools and automatic Why? provenance use Matchless IR and compiler metadata after type checking. These are related compiler stages, not literally one representation, and this page does not claim that runtime observations and static reports can never disagree. Takeaway: each claim must be checked at its owning layer. The trace field in the explanatory thunk snippets is runtime metadata; it is not evidence that UIAnalyzer and Explorer consume one identical IR. Read UIAnalyzer.scala for UI extraction and SystemProfileAnalyzer.scala for Explorer analysis.

Execution Layers and Source Locations

Takeaway: the table maps each runtime responsibility to the source that owns it. Use those links when a generated artifact differs from this explanatory page.

LayerWhat happensWhere in code
Bosatsu source write(c, v) returns IO[Unit], an inert value counter.bosatsu
JVM runtime deferredUiMutation wraps mutation in FlatMap ADT node UI.scala
Static analysis UIAnalyzer extracts DOMBinding(elementId, textContent, statePath) UIAnalyzer.scala
JS codegen write → _ui_write_io(state, value) (returns a thunk) JsGen.scala
JS runtime _ui_write sets value, queues _updateBindings via microtask UIGen.scala
DOM update _updateBindings does el.textContent = "1" with no diff and no rerender UIGen.scala

Compile and Inspect the Generated Counter

The linked generated counter compiles from demos/ui/counter.bosatsu. Needs a JVM 17+ and sbt:

git clone https://github.com/snoble/yichus && cd yichus
sbt assembly
java -jar target/scala-*/yichus.jar sim \
  --package Demo/Counter --main main \
  --output counter.html demos/ui/counter.bosatsu

Open counter.html in a browser: that file contains the generated JS from step 4 and the embedded binding map from step 3, verbatim.

Where this fits