Technical Overview
This document describes the architecture and APIs of the Xote reactive core and minimal component renderer, inspired by the TC39 Signals proposal. It summarizes the data model, dependency tracking, scheduling, and module boundaries.
Reference: TC39 Signals proposal https://github.com/tc39/proposal-signals.
Modules and Responsibilities
Xote__Core: Low-level runtime for dependency tracking and scheduling of observers (effects and computeds). Defines the signal cell shape (t<'a>) and maintains global maps of dependencies and observers. Potentially some of these modules could be moved into other modules (e.g observers scheduling toXote__Observer, signal observers toXote__Signal, and so on).Xote__Signal: User-facing state cells.make,get,peek,set,updatewith automatic dependency capture onget.Xote__Computed: Derived signals. Creates an internal observer that recomputes and writes into a backing signal.Xote__Effect: Effects that run a function tracked against any signals it reads. Returns a disposer fn.Xote__Observer: Observer types and structure used by the scheduler.Xote__Id: Monotonic integer ID generator.Xote__Component: Minimal virtual DOM with reactive text and fragment nodes, render and mount to DOM. Convenience element constructors.Xote__Route: Pure route matching logic with pattern-based string matching. Supports dynamic parameters using:paramsyntax.Xote__Router: Signal-based router with browser History API integration. Provides location signal, imperative navigation, declarative routing components, and SPA navigation links.Xote: Public module surface that re-exports the above.
Core Data Structures (Xote__Core)
- Signal cell:
t<'a> = { id: int, value: ref<'a>, version: ref<int> }. - Global state:
observers: Map<int, Observer.t>— all observers by id.signalObservers: Map<int, Set<int>>— signal id -> set of observer ids.currentObserverId: option<int>— the observer currently tracking reads.pending: Set<int>andbatching: bool— simple synchronous scheduler queue and batching flag.
Dependency Tracking
- Reads under tracking are captured: when
currentObserverIdisSome(id),Signal.getcallsCore.addDep(id, signalId). addDepboth records the dependency in the observer and adds the observer to the signal’s reverse index.clearDepsremoves an observer from all its dependency buckets before re-tracking.
Scheduling Model
notify(signalId)looks up dependent observers and enqueues them viaschedule.schedule(observerId)queues the observer; if not batching, it immediately flushes: clears previous deps, setscurrentObserverId, runs the observer’srun, then unsets tracking.batch(f)setsbatching = truefor the duration offand flushes the queued observers afterward.untrack(f)temporarily disables dependency capture duringf.
Semantics:
- Synchronous, immediate scheduling by default (microtask-like but runs inline when not batching).
- Re-execution always re-tracks dependencies to reflect the latest graph.
Signals (Xote__Signal)
make(v)creates a new cell.get(s)returns the value and, if tracking, captures a dependency.peek(s)returns the value without dependency capture.set(s, v)writes the value, increments a version counter, andnotifys dependents.update(s, f)convenience helper that setsf(get(s)).
Notes:
- No equality check on
set; every write notifies.
Computed (Xote__Computed)
make(calc)creates a backing signalsand registers an observer whoserunrecomputescalc()and writes intos.- Initial compute runs under tracking to establish dependencies.
- On dependency writes, the core re-runs the computed’s observer which pushes the new value into
sand notifies any downstream dependents ofs.
Implication:
- Computeds are realized via push into a backing signal rather than being lazily re-evaluated only on read. They are re-evaluated when upstream dependencies notify and the scheduler flushes.
Effects (Xote__Effect)
run(fn)registers an observer of kind#Effectand runsfnunder tracking. Returns{ dispose }to stop observing.- On dependency writes, the effect re-runs synchronously (or after batching) and re-tracks.
Notes:
- No explicit cleanup callback API (e.g., returning a disposer from
fn); explicitdispose()removes the observer and clears deps.
Observers (Xote__Observer)
type kind = [ #Effect | #Computed(int) ]— computed carries the id of its backing signal.type t = { id, kind, run, mutable deps }— internal unit of scheduling.
Component/Rendering (Xote__Component)
- Virtual node types:
Element,Text,SignalText(Core.t<string>),Fragment,SignalFragment(Core.t<array<node>>)\. - Builders:
text,textSignal,fragment,signalFragment,list(signal, renderItem),elementand tag helpers (div,span,button,input,h1, etc.). - Rendering:
SignalTextcreates a text node seeded withpeek, and an effect that reads viagetand writestextContenton change.SignalFragmentuses an effect to replace its container's children when the array signal changes.listis built with a computed that maps the array signal through the item renderer and is rendered as aSignalFragment.
- Mounting:
mount(node, container)andmountById(node, containerId).
Router (Xote__Route and Xote__Router)
Route Matching (Xote__Route)
- Pattern-based string matching with
:paramsyntax for dynamic segments. parsePattern(pattern)converts a route pattern like/users/:idinto an array ofsegment(eitherStatic(string)orParam(string)).matchPath(pattern, pathname)returnsMatch(params)with extracted parameters orNoMatch.match(pattern, pathname)is a convenience function that parses and matches in one call.- Parameters are returned as
Dict.t<string>for flexible access.
Notes:
- No regex complexity; simple string-based matching covers common use cases.
- All matching is synchronous and deterministic.
Router State and Navigation (Xote__Router)
- Location signal:
location: Core.t<location>wherelocation = {pathname, search, hash}. - Initialization:
init()sets initial location from browser and addspopstatelistener for back/forward buttons. - Imperative navigation:
push(pathname, ())navigates with a new history entry usingpushState.replace(pathname, ())navigates without a new history entry usingreplaceState.
- Declarative routing components:
route(pattern, render)renders a component when the pattern matches the current location.routes(configs)renders the first matching route from an array of{pattern, render}configs.- Both use
SignalFragment+Computedinternally for reactive rendering.
- Navigation links:
link(~to, ~children, ())creates an anchor element that callspushon click (prevents page reload).
Characteristics:
- Router location is a signal, so all components reading it automatically re-render on navigation.
- Browser History API integration ensures back/forward buttons work correctly.
- Synchronous route matching aligns with Xote's default scheduling model.
- Zero dependencies; uses only browser APIs and Xote primitives.
Execution Characteristics
- Push vs Pull: Signals push notifications to observers; computeds eagerly push into their backing signal upon upstream changes. Effects run synchronously (unless wrapped in
batch). - Reactivity Graph: Auto-tracked; observers re-track on every run to maintain an accurate dependency set.
- Batching: Groups multiple writes; flush runs after the batch completes.
Relation to TC39 Signals Proposal
- Aligned concepts:
- Cells/signals with automatic dependency tracking on read, invalidation on write.
- Observer-based recomputation and re-tracking; batching and untracked execution helpers.
- Notable differences from the current proposal draft:
- Computeds are realized via a backing state signal and are re-evaluated on upstream notification (eager push), whereas the proposal emphasizes pull-evaluation on demand.
- Effects here are regular observers that run user code and may read signals during execution; the proposal’s low-level
Watcher.notifyis synchronous and not permitted to read or write signals. - No subtle namespace or formalized
versioned/dirtysemantics; a simpleversioncounter is maintained per signal but is not exposed. - Scheduler flush is synchronous and inline (microtask-like semantics are not modeled explicitly).
- No built-in cleanup/teardown API for effects beyond
dispose(); no error handling or cancellation API.
See the TC39 draft for the intended semantics and motivations: https://github.com/tc39/proposal-signals.
API Summary
Signal.make : 'a -> Core.t<'a>Signal.get : Core.t<'a> -> 'aSignal.peek : Core.t<'a> -> 'aSignal.set : (Core.t<'a>, 'a) -> unitSignal.update : (Core.t<'a>, 'a -> 'a) -> unitComputed.make : (unit -> 'a) -> Core.t<'a>Effect.run : (unit -> unit) -> { dispose: unit -> unit }Core.batch : (unit -> 'a) -> 'aCore.untrack : (unit -> 'a) -> 'a
Rendering helpers (selected):
Component.text : string -> nodeComponent.textSignal : (unit -> string) -> nodeComponent.signalFragment : Core.t<array<node>> -> nodeComponent.list : (Core.t<array<'a>>, 'a -> node) -> nodeComponent.element : (~attrs=?, ~events=?, ~children=?, unit) -> nodeand tag helpers.Component.mount : (node, Dom.element) -> unitComponent.mountById : (node, string) -> unit
Router helpers:
Router.init : unit -> unitRouter.location : Core.t<{pathname: string, search: string, hash: string}>Router.push : (string, ~search: string=?, ~hash: string=?, unit) -> unitRouter.replace : (string, ~search: string=?, ~hash: string=?, unit) -> unitRouter.route : (string, Route.params -> Component.node) -> Component.nodeRouter.routes : array<{pattern: string, render: Route.params -> Component.node}> -> Component.nodeRouter.link : (~to: string, ~attrs: array<(string, Component.attrValue)>=?, ~children: array<Component.node>=?, unit) -> Component.nodeRoute.match : (string, string) -> matchResultwherematchResult = Match(Dict.t<string>) | NoMatch
Known Limitations and Future Work
- Computed laziness differs from the proposal; to be considered pull-style recomputation.
- Microtask-based scheduling and consolidation of redundant recomputations.
- Effect cleanup hooks and error handling.
- Optional equality/comparator for
setto avoid unnecessary notifications. - More granular DOM updates for fragments and lists (diffing instead of replace-all).
- Remove redundancy from signal consumption when using computed within components.