Reactive.js
The complete source of src/Reactive.js, exactly as it ships, in reading order. Every line is present; the file is split at its own section banners.
Reactive.js - the self-contained reactive framework
// ============================================================================
// Reactive.js - the self-contained reactive framework
// ============================================================================
//
// One dependency-free file that defines the global `Reactive` and nothing else.
// Every other file in this directory (Dom, Content, Layout, Button, Input, Maths,
// Clip.*) is authored AGAINST this: it calls Reactive.Module/Function/Type/Union/
// Field/Variant to declare things, and the runtime (State/Calculate/Reverse/Binding/
// Set/Run/If/Each) to make them live.
//
// Two layers live here (see docs/13-reactive-js-implementation.md):
// 1. the runtime - state, calculations, reverse calculations, control flow,
// locking and errors. A reshape of the proven phase-zero
// implementation (program/phase-zero/.../Reactive/native/js).
// 2. reification - Module/Function/Type/Union/Field/Variant and the dotted global
// namespace they build.
// Contexts, the typed reversible Proxy accessors, and the driver infrastructure the
// DOM driver (Dom.js) realises are layered on top in later sections.
//
// The internal value model (ReactiveValue and friends) is PRIVATE to this file:
// consumers never hold one directly, they pass bindings opaquely or hold Proxies.
//
// Reversibility (the key feature), in one paragraph: a binding carries a reverse
// callback. Reactive.Set(binding, v) calls it synchronously with the new native
// value; the callback is plain forward code that ends by Set-ting OTHER bindings.
// Those sets fan out into a TREE whose leaves are State writes. If any leaf is locked
// (an async step in flight) the whole reverse process is kept "unfinished" and its
// destination states are marked locked until a driver unlocks them. That single
// mechanism is the entire async/transaction/locking story.
// ----------------------------------------------------------------------------
;(function () {
'use strict'
Section 1 - internal value model (private)
// ==========================================================================
// Section 1 - internal value model (private)
// ==========================================================================
//
// One container, ReactiveValue { optionalGettable, optionalSettable }:
// gettable only = a constant or a Calculate result (read only)
// settable only = a Reverse(cb) write sink
// both = a two-way binding (a State, or Binding(get, set))
// An error attached to a lock. optionalRetryFlagId, when set, is a settable id:
// Set that flag to true to trigger a retry.
class ReactiveError {
constructor(message, optionalRetryFlagId) {
this.message = message
this.optionalRetryFlagId = optionalRetryFlagId
}
}
// Present on a gettable when the value is locked. optionalError null = loading.
class ReactiveLock {
constructor(optionalError) {
this.optionalError = optionalError
}
}
// The readable half. value is the native payload; it may itself be undefined for
// a nullable type, independent of whether the value is gettable.
//
// TWO locks, distinct in effect:
// optionalLock - the READ lock: the value is not ready (loading). When a UI area
// reads it to display, a loading spinner appears around that area.
// optionalWriteLock - the WRITE lock: the value can still be read/displayed, but it may
// not be SET right now (an ongoing reverse process reads or writes
// it). A settable control bound to it (Button.Set, an input) is
// disabled. A reverse destination carries BOTH.
class ReactiveGettable {
constructor(optionalLock, value, optionalWriteLock) {
this.optionalLock = optionalLock
this.value = value
this.optionalWriteLock = optionalWriteLock
}
}
// The writable half. id is the stable settable id; optionalReverseCallback, when
// present, is run synchronously by Reactive.Set (a Reverse sink); when absent the
// settable is a State leaf and Set records the write.
class ReactiveSettable {
constructor(id, optionalReverseCallback) {
this.id = id
this.optionalReverseCallback = optionalReverseCallback
}
}
class ReactiveValue {
constructor(optionalGettable, optionalSettable) {
this.optionalGettable = optionalGettable
this.optionalSettable = optionalSettable
}
}
// ---- lock trees ------------------------------------------------------------
//
// A gettable's optionalLock / optionalWriteLock is a LOCK TREE (or null): locks scoped to
// paths in the value's field structure. A node is { direct, fields }:
// direct - a ReactiveLock (carrying optionalError) locking THIS exact path, or null
// fields - { name: LockTree } - locks on descendant fields, siblings independent
// PRUNED invariant: a subtree exists only when it, or something below it, is directly
// locked - so a NON-NULL tree always means "something at or below this path is locked".
// That is exactly the field-level rule: a lock on an object covers every field DOWN
// (lockChild pushes `direct` onto each field); a locked field makes its container locked
// UP (the container's tree is non-null) while its SIBLINGS stay free (their subtrees are
// absent). A whole-value lock is just a leaf: { direct: lock, fields: {} }.
function lockLeaf(reactiveLock) {
return reactiveLock == null ? null : { direct: reactiveLock, fields: {} }
}
// Slice a tree DOWN to one field: an ancestor's direct lock pushes onto the field (a locked
// object locks each of its fields), keeping any finer structure beneath it.
function lockChild(tree, name) {
if (tree == null) {
return null
}
const own = tree.fields[name] == null ? null : tree.fields[name]
if (tree.direct != null) {
const fields = own == null ? {} : own.fields
return { direct: tree.direct, fields: fields }
}
return own
}
// Wrap a subtree under a path (root..leaf): path ['location','lat'] + a leaf lock becomes
// { fields: { location: { fields: { lat: <leaf> } } } }. An empty path returns the subtree.
function lockScope(path, subtree) {
if (subtree == null) {
return null
}
let node = subtree
for (let i = path.length - 1; i >= 0; i--) {
const fields = {}
fields[path[i]] = node
node = { direct: null, fields: fields }
}
return node
}
// Union of two trees (all locked paths of both). A direct error beats a direct loading lock.
function lockMerge(a, b) {
if (a == null) {
return b
}
if (b == null) {
return a
}
const aErr = a.direct != null && a.direct.optionalError != null
const bErr = b.direct != null && b.direct.optionalError != null
const direct = aErr ? a.direct : (bErr ? b.direct : (a.direct != null ? a.direct : b.direct))
const fields = {}
for (const k of Object.keys(a.fields)) {
fields[k] = a.fields[k]
}
for (const k of Object.keys(b.fields)) {
fields[k] = fields[k] == null ? b.fields[k] : lockMerge(fields[k], b.fields[k])
}
return { direct: direct, fields: fields }
}
// The first error anywhere in the tree (direct before descendants), or null.
function lockFirstError(tree) {
if (tree == null) {
return null
}
if (tree.direct != null && tree.direct.optionalError != null) {
return tree.direct.optionalError
}
for (const k of Object.keys(tree.fields)) {
const found = lockFirstError(tree.fields[k])
if (found != null) {
return found
}
}
return null
}
// Collect every error in the tree into `into` (for combined retry-flag handling).
function lockAllErrors(tree, into) {
if (tree == null) {
return into
}
if (tree.direct != null && tree.direct.optionalError != null) {
into.push(tree.direct.optionalError)
}
for (const k of Object.keys(tree.fields)) {
lockAllErrors(tree.fields[k], into)
}
return into
}
// A stable-identity tree node. branches is name -> child scope-item list (If/Each/
// Branches). It holds no values; persisted values live in ReactiveProgram.values.
class ReactiveScopeItem {
constructor(id, branches) {
this.id = id
this.branches = branches
}
}
// A forward or reverse process: a callback plus the root scope items that give it
// stable ids across runs.
class ReactiveProcess {
constructor(callback, rootScopeItems) {
this.callback = callback
this.rootScopeItems = rootScopeItems
}
}
// The whole program across runs. reverseProcesses is settableId -> unfinished
// process; values is stableId -> ReactiveValue (the state memory).
class ReactiveProgram {
constructor(forwardProcess, reverseProcesses, values) {
this.forwardProcess = forwardProcess
this.reverseProcesses = reverseProcesses
this.values = values
}
}
// A reactive array value and its keyed elements (used by Each and, later, by the
// array accessors).
class ReactiveArray {
constructor(elements) {
this.elements = elements
}
}
class ReactiveArrayElement {
constructor(key, value) {
this.key = key
this.value = value
}
}
Section 1b - security (strict base checks)
// ==========================================================================
// Section 1b - security (strict base checks)
// ==========================================================================
//
// The always-on structural checks: a value is gettable / settable / reverse /
// binding as required. Strict TYPE validation (against reified descriptors) layers
// on top of these in a later section.
const Security = {
Gettable(valueOrReactiveValue) {
if (!(valueOrReactiveValue instanceof ReactiveValue)) {
return
}
if (valueOrReactiveValue.optionalGettable == null) {
throw new Error('Value is not gettable: it is settable-only')
}
},
OptionalGettable(optionalValueOrReactiveValue) {
if (optionalValueOrReactiveValue == null) {
return
}
Security.Gettable(optionalValueOrReactiveValue)
},
Settable(reactiveValue) {
if (!(reactiveValue instanceof ReactiveValue) || reactiveValue.optionalSettable == null) {
throw new Error('Value is not settable')
}
},
Reverse(reactiveValue) {
Security.Settable(reactiveValue)
if (reactiveValue.optionalSettable.optionalReverseCallback == null) {
throw new Error('Value is not reverse-settable: it has no reverse callback')
}
},
Binding(reactiveValue) {
Security.Gettable(reactiveValue)
Security.Settable(reactiveValue)
},
}
Section 2 - runtime engine (reshaped from phase-zero)
// ==========================================================================
// Section 2 - runtime engine (reshaped from phase-zero)
// ==========================================================================
//
// Same names and semantics as the phase-zero JS runtime; renamed
// optionalNativeReverseCallback -> optionalReverseCallback and dropped the
// auto-generated banners. The algorithm (reverse-tree locking, slot-by-index stable
// identity, forward lock/error combination) is unchanged.
class Reactive {
// ---- per-run engine state -------------------------------------------------
// Source settable values for the current Run: program.values merged with state
// updates and reverse batches.
static _sourceValueMap = {}
// Destination settable values gathered during the current Run (State/Reverse
// persist into here).
static _destinationValueMap = {}
// Global counter for unique integer ids (scope-item and settable ids).
static _nextIdCounter = 1
// Values read via Reactive.Value during a Calculate, so Calculate can combine
// their locks/errors into its output.
static _calculateValues = []
// Ids explicitly Set on State leaves inside the current reverse process (null
// when outside a reverse process). Used to lock only the actual Set targets.
static _reverseSetTargets = null
// Values READ (via Reactive.Value) inside the current reverse process (null when
// not in one). An unfinished reverse write-locks the STATE leaves among these - the
// stable inputs it read from its closure - so nothing else changes them mid-flight.
static _reverseReadValues = null
// The lock tree of the value currently being Set (stashed by Set around a reverse
// callback), so a field reverse can carry the incoming read-lock, scoped, up to the
// backing state. null when the set value is unlocked or outside a Set.
static _reverseInputLock = null
// The field path (root..field) currently being written, threaded by the _objectField
// reverses as a set cascades UP to its backing state; [] for a whole-value set.
static _reverseFieldPath = []
// Per set-target id, the list of field paths written in the current reverse process, so
// the process can scope its WRITE lock to exactly those paths (null when outside one).
static _reverseSetPaths = null
// Slot-by-index scope-item lists for the current scope run.
static _sourceScopeItems = []
static _destinationScopeItems = []
// Emission frames for Emit/Gather. One frame per running Reactive.Function; a
// frame is channel -> [values]. Gather drains the top frame; on function return
// un-gathered channels bubble into the caller's frame (see Section 6).
static _emissionFrameStack = []
// After a Run, the forward pass's root frame: whatever bubbled to the top with no
// function Gather catching it. Drivers drain their channel from here (Section 7).
static _rootEmissions = {}
// After a Run, the value the forward callback RETURNED: a structural region's gathered
// output as a { channel: [values] } map (e.g. { content: nodeList }). Mount FOLDS this into
// _rootEmissions so structural output reaches its driver by the same path as an ambient
// signal - there is no privileged channel, drivers all just drain their channel from root.
// _lastCallbackResult is the scratch the shared _runCallback writes on every call;
// _programResult is the forward callback's result, read once right after the forward pass.
static _lastCallbackResult = null
static _programResult = null
// TYPE-CHECK MODE. When true (only during Reactive.Mount's static pass), the forward run is a
// whole-program TYPE traversal, not a real render: it follows EVERY branch (an If runs its
// body regardless of the condition; an Each runs its item callback once) so type-inconsistent
// channels on any path are found before the program starts. Termination is by DEPTH, not by
// function identity: scope functions like Dom are legitimately re-entrant (every child tag
// re-enters Dom), so an identity guard would refuse to descend into the tree. Instead a call
// stack deeper than _typeCheckDepthLimit is treated as runaway recursion (branch coverage
// forces recursive branches that real data would stop) and the over-deep call returns a
// permissive placeholder (any property is an empty array) so nothing crashes.
static _typeChecking = false
static _typeCheckDepth = 0
static _typeCheckDepthLimit = 300
// ---- run loop -------------------------------------------------------------
// Core execution: apply updates, run pending + new reverse processes, run the
// forward process, and return a new program (new scope tree, unfinished reverse
// processes, fresh values map).
static Run(program, optionalUpdates = null) {
const oldSourceValueMap = Reactive._sourceValueMap
const oldDestinationValueMap = Reactive._destinationValueMap
const oldSourceScopeItems = Reactive._sourceScopeItems
const oldDestinationScopeItems = Reactive._destinationScopeItems
const updates = optionalUpdates == null ? {} : optionalUpdates
const stateUpdates = Reactive._getStateUpdates(updates, program.values)
Reactive._sourceValueMap = Reactive._mergeUpdates(program.values, stateUpdates)
Reactive._destinationValueMap = {}
const reverseUpdates = Reactive._getReverseUpdates(updates, program.values)
const createdReverseProcesses = Reactive._createReverseProcesses(reverseUpdates)
const reverseProcesses = Object.assign({}, program.reverseProcesses, createdReverseProcesses)
const previousEmissionFrameStack = Reactive._emissionFrameStack
// ONE root frame for the whole cycle. Reverse processes run first and emit into it
// (so a reverse can hand async work - a request - to a driver), then the forward
// pass emits into it too. Everything that bubbles to the top is the drivers' work.
Reactive._emissionFrameStack = [{}]
const nextReverseProcesses = Reactive._runReverseProcesses(reverseProcesses)
const updatedForwardScopeItems = Reactive._runCallback(program.forwardProcess.rootScopeItems, program.forwardProcess.callback)
// The forward callback is the outermost _runCallback and returns last, so its result
// is what _lastCallbackResult holds now (nested Branches callbacks all returned earlier).
Reactive._programResult = Reactive._lastCallbackResult
Reactive._rootEmissions = Reactive._emissionFrameStack[0]
Reactive._emissionFrameStack = previousEmissionFrameStack
const updatedForwardProcess = new ReactiveProcess(program.forwardProcess.callback, updatedForwardScopeItems)
const newValues = Reactive._destinationValueMap
Reactive._sourceValueMap = oldSourceValueMap
Reactive._destinationValueMap = oldDestinationValueMap
Reactive._sourceScopeItems = oldSourceScopeItems
Reactive._destinationScopeItems = oldDestinationScopeItems
return new ReactiveProgram(updatedForwardProcess, nextReverseProcesses, newValues)
}
// Build a ReactiveProgram from a program callback.
static Program(programCallback) {
const rootItems = []
const forwardProcess = new ReactiveProcess(programCallback, rootItems)
return new ReactiveProgram(forwardProcess, {}, {})
}
// ---- control flow ---------------------------------------------------------
// Create branches at the current scope position for conditional logic and
// iteration. scopeCallback receives a scope(name, body) function.
static Branches(branchCallback) {
const destinationScopeItems = Reactive._destinationScopeItems
const item = Reactive._nextSourceScopeItem()
const scope = (scopeName, scopeCallback) => {
const optionalBranchSourceScopeItems = item.branches[scopeName]
const branchSourceScopeItems = optionalBranchSourceScopeItems == null ? [] : optionalBranchSourceScopeItems
const branchDestinationScopeItems = Reactive._runCallback(branchSourceScopeItems, scopeCallback)
item.branches[scopeName] = branchDestinationScopeItems
}
branchCallback(scope)
destinationScopeItems.push(item)
}
// Run onTrue only when condition (raw or reactive boolean) is true. In type-check mode the
// condition is ignored and the body is always entered, so the branch is covered.
static If(condition, onTrue) {
Reactive.Branches((scope) => {
const rawValue = condition instanceof ReactiveValue
? (condition.optionalGettable == null ? false : condition.optionalGettable.value)
: condition
const conditionValue = typeof rawValue === 'boolean' ? rawValue : !!rawValue
if (conditionValue || Reactive._typeChecking) {
scope('if', () => {
onTrue()
})
}
})
}
// Iterate a ReactiveArray, one named branch scope per element (keyed by element
// key). While the array is locked, itemCallback is not invoked.
static Each(reactiveArray, itemCallback) {
Security.Gettable(reactiveArray)
const isLocked = Reactive.IsLocked(reactiveArray)
const native = reactiveArray instanceof ReactiveValue
? (reactiveArray.optionalGettable == null ? null : reactiveArray.optionalGettable.value)
: reactiveArray
const list = isLocked || native == null ? [] : native.elements
Reactive.Branches((scope) => {
// Type-check mode: cover the item callback exactly once even when the array is empty at
// startup, using a trusted placeholder element (its type is declared by the callback's
// own Reactive.Argument, so the element is passed through untouched).
if (Reactive._typeChecking && list.length === 0) {
scope('typecheck', () => {
const placeholder = Reactive.Create(undefined)
placeholder[TYPE_SYMBOL] = { __typeCheckElement: true }
itemCallback(placeholder)
})
return
}
for (const elementData of list) {
scope(elementData.key, () => {
const reactiveValue = Reactive.Create(elementData.value)
itemCallback(reactiveValue)
})
}
})
}
// ---- stable identity ------------------------------------------------------
// A stable id for the current call-site position.
static StableId() {
const destinationScopeItems = Reactive._destinationScopeItems
const item = Reactive._nextSourceScopeItem()
destinationScopeItems.push(item)
return item.id
}
// A stable string key for collections/contexts.
static StableKey(optionalPrefix) {
const id = Reactive.StableId()
const key = id.toString()
return optionalPrefix == null ? key : optionalPrefix + key
}
// ---- values ---------------------------------------------------------------
// Extract the native value from a value or ReactiveValue. Inside a Calculate this
// also records the value so its lock/error combines into the result.
static Value(valueOrReactiveValue) {
Security.Gettable(valueOrReactiveValue)
if (!(valueOrReactiveValue instanceof ReactiveValue)) {
return valueOrReactiveValue
}
const reactiveValue = valueOrReactiveValue
Reactive._calculateValues.push(reactiveValue)
if (Reactive._reverseReadValues != null) {
Reactive._reverseReadValues.push(reactiveValue)
}
return reactiveValue.optionalGettable.value
}
// A mutable reactive state value (a two-way binding to a persisted leaf). CURRIED like
// every other generic (Fn(type)(value)): the type is the generic parameter and is always
// given - State(Text)("info"), State(Clip.Object.Data)(objectNative). For a loosely-typed
// leaf use Reactive.Any: State(Reactive.Any)(0).
static State(type) {
return function (initialValue) {
return Reactive._makeState(initialValue, type, false)
}
}
// Like State, but a NEWLY created value starts locked (loading). On later runs the
// persisted value is used - which a driver has typically unlocked with real data. This
// is the async-initial pattern: Network.Web.Client.Request returns a Loading state that
// its driver fills in, so the UI shows a spinner until the data arrives. Curried the same
// way: Loading(Network.Web.Response)(empty).
static Loading(type) {
return function (initialValue) {
return Reactive._makeState(initialValue, type, true)
}
}
static _makeState(initialValue, optionalType, initialLocked) {
const id = Reactive.StableId()
const optionalExisting = Reactive._sourceValueMap[id]
const descriptor = Reactive.DescriptorOf(optionalType)
let value
if (optionalExisting != null) {
const existingGettable = new ReactiveGettable(optionalExisting.optionalGettable.optionalLock, optionalExisting.optionalGettable.value, optionalExisting.optionalGettable.optionalWriteLock)
const existingSettable = new ReactiveSettable(optionalExisting.optionalSettable.id, undefined)
value = new ReactiveValue(existingGettable, existingSettable)
} else {
const rawInitial = initialValue instanceof ReactiveValue ? initialValue.optionalGettable.value : initialValue
if (descriptor != null) {
validateAgainstDescriptor(rawInitial, descriptor, descriptor.name)
}
const optionalLock = initialLocked ? lockLeaf(new ReactiveLock(undefined)) : undefined
const gettable = new ReactiveGettable(optionalLock, rawInitial)
const settable = new ReactiveSettable(id, undefined)
value = new ReactiveValue(gettable, settable)
}
Security.Settable(value)
Reactive._destinationValueMap[id] = value
if (descriptor != null) {
return Reactive._typed(value, descriptor)
}
return value
}
// Normalise a value into a ReactiveValue. If already reactive, returned as-is;
// otherwise wrapped as a constant (optionally locked / errored).
static Create(value, optionalLocked, optionalErrorMessage, optionalRetryFlagId) {
Security.Gettable(value)
if (value instanceof ReactiveValue) {
return value
}
const optionalError = optionalErrorMessage != null ? new ReactiveError(optionalErrorMessage, optionalRetryFlagId) : undefined
const optionalLock = optionalLocked === true ? lockLeaf(new ReactiveLock(optionalError)) : undefined
const gettable = new ReactiveGettable(optionalLock, value)
return new ReactiveValue(gettable, undefined)
}
// Run a compute callback and combine the locks/errors of every input read via Reactive.Value
// into a { value, optionalLock }. This is where the callback actually RUNS - in the
// 'calculate' driver, not synchronously in the program. The callback must not throw.
static _runCalculation(nativeCalculationCallback) {
const previousCalculateValues = Reactive._calculateValues
Reactive._calculateValues = []
const result = nativeCalculationCallback()
const calculateValues = Reactive._calculateValues
Reactive._calculateValues = previousCalculateValues
const resultReactive = Reactive.Create(result)
const resultValue = resultReactive.optionalGettable.value
const allValues = [resultReactive].concat(calculateValues)
const trees = Reactive._getLocks(allValues)
const errors = Reactive._getErrors(trees)
const retryFlagIds = Reactive._getRetryFlagIds(errors)
const isLocked = trees.length > 0
const hasError = errors.length > 0
const combinedRetryFlagId = Reactive._generateCombinedRetryFlagId(retryFlagIds)
// A Calculate result is a fresh scalar (no field structure), so its lock is a leaf: it
// is locked iff any input value is locked-at-its-path (its sliced tree is non-null).
const optionalError = hasError ? new ReactiveError('Multiple errors', combinedRetryFlagId) : undefined
const optionalLock = isLocked ? lockLeaf(new ReactiveLock(optionalError)) : undefined
return { value: resultValue, optionalLock: optionalLock }
}
// Reactive.Calculate is REMOVED from the program surface - commented out completely. Arbitrary
// inline computation is not allowed: every derived value must be a typed OPERATION with a
// driver (Maths.Add, Text.Concat, ...), so the whole system is driver-based and statically
// type-checkable. The lock-combining machinery survives as Reactive._runCalculation, which the
// operation drivers use internally; there is no public entry point. A call site that still
// reaches for it fails loudly (undefined is not a function), so remaining uses are found.
//
// static Calculate(nativeCalculationCallback) {
// const result = Reactive.Loading(Reactive.Any)(undefined)
// Reactive.Emit('calculate', { callback: nativeCalculationCallback, result: result })
// return result
// }
// ---- reverse (the core of two-way binding) --------------------------------
// Create a settable-only value with a reverse callback, run when it is Set. Pair
// it with a gettable via Binding to make a two-way binding.
static Reverse(reverseCallback) {
const id = Reactive.StableId()
const settable = new ReactiveSettable(id, reverseCallback)
const reactiveValue = new ReactiveValue(undefined, settable)
Reactive._destinationValueMap[id] = reactiveValue
return reactiveValue
}
// Combine a gettable value with a settable into a two-way binding.
static Binding(gettable, settable) {
Security.Gettable(gettable)
Security.Settable(settable)
return new ReactiveValue(gettable.optionalGettable, settable.optionalSettable)
}
// Set a value on a binding. A reverse sink runs its callback synchronously (which
// typically Sets further bindings, forming the reverse tree); a State leaf records
// the write and is tracked as a Set target for lock combination.
static Set(reactiveValue, newValueOrReactiveValue) {
Security.Settable(reactiveValue)
const id = reactiveValue.optionalSettable.id
const optionalReverseCallback = reactiveValue.optionalSettable.optionalReverseCallback
const newReactiveValue = Reactive.Create(newValueOrReactiveValue)
const newOptionalLock = newReactiveValue.optionalGettable.optionalLock
const newValue = newReactiveValue.optionalGettable.value
// Strict typing: if this binding carries a type, validate the new native value.
// Skipped while locked (a locked value may carry a placeholder payload).
const descriptor = reactiveValue[TYPE_SYMBOL]
if (descriptor != null && newOptionalLock == null) {
validateAgainstDescriptor(newValue, descriptor, descriptor.name)
}
if (optionalReverseCallback != null) {
// Hand the incoming value's lock tree to the reverse (a field reverse scopes it under
// its field name and carries it up); save/restore so nested Sets don't clobber it.
const previousInputLock = Reactive._reverseInputLock
Reactive._reverseInputLock = newOptionalLock
try {
optionalReverseCallback(newValue)
} finally {
Reactive._reverseInputLock = previousInputLock
}
} else {
if (Reactive._reverseSetTargets != null) {
Reactive._reverseSetTargets.push(id)
// Record which field path of this state was written (threaded up by the field
// reverses; [] for a whole-value set), so the reverse process scopes its write lock.
if (Reactive._reverseSetPaths != null) {
const existingPaths = Reactive._reverseSetPaths[id] == null ? [] : Reactive._reverseSetPaths[id]
existingPaths.push(Reactive._reverseFieldPath.slice())
Reactive._reverseSetPaths[id] = existingPaths
}
}
const gettable = new ReactiveGettable(newOptionalLock, newValue)
const settable = new ReactiveSettable(id, undefined)
Reactive._destinationValueMap[id] = new ReactiveValue(gettable, settable)
}
}
// PRIVATE. The reversible field step the typed Proxy accessors (section 5) are built on.
// Forward: read the field, with its lock sliced from the object's lock tree so each field
// is locked EXACTLY (an ancestor lock covers it, a sibling's lock does not). Reverse:
// rebuild the object with the field replaced and Set the object, carrying the incoming
// value's lock scoped under this field and threading this field onto the write path.
// Not public: application code reaches fields through the Proxy dotted notation.
static _objectField(object, rawFieldGetterFunction, rawFieldChangerFunction, fieldName) {
Security.Gettable(object)
const objectGettable = object instanceof ReactiveValue ? object.optionalGettable : null
const objectNative = objectGettable == null ? object : objectGettable.value
const rawField = rawFieldGetterFunction(objectNative)
// CASE B (direct-backing): the field's value IS itself a settable binding (a State or
// Reverse stuffed into a plain object). Return it directly, so reads and writes go
// straight to that backing regardless of whether the container is settable. This is what
// lets a settable field live inside a gettable-only object and just work.
if (rawField instanceof ReactiveValue) {
return rawField
}
// CASE A: an ordinary native field. Slice the object's lock trees down to this field.
const objectReadTree = objectGettable == null ? undefined : objectGettable.optionalLock
const objectWriteTree = objectGettable == null ? undefined : objectGettable.optionalWriteLock
const fieldReadTree = lockChild(objectReadTree, fieldName)
const fieldWriteTree = lockChild(objectWriteTree, fieldName)
const fieldGettable = new ReactiveGettable(fieldReadTree, rawField, fieldWriteTree)
if (object instanceof ReactiveValue && object.optionalSettable != null) {
const settable = Reactive.Reverse((newFieldValue) => {
const incomingLock = Reactive._reverseInputLock
const currentGettable = object.optionalGettable
const currentNative = currentGettable == null ? undefined : currentGettable.value
const updatedNative = rawFieldChangerFunction(currentNative, newFieldValue)
// Carry the incoming read-lock scoped under this field (a field set to a loading
// value keeps THAT field, and it alone, read-locked at the backing state).
const scopedLock = lockScope([fieldName], incomingLock)
const updatedValue = new ReactiveValue(new ReactiveGettable(scopedLock, updatedNative), undefined)
Reactive._reverseFieldPath.unshift(fieldName)
try {
Reactive.Set(object, updatedValue)
} finally {
Reactive._reverseFieldPath.shift()
}
})
return new ReactiveValue(fieldGettable, settable.optionalSettable)
}
return new ReactiveValue(fieldGettable, undefined)
}
// ---- references -----------------------------------------------------------
// The settable id of a binding, so it can be passed through contexts that would
// otherwise unwrap the ReactiveValue.
static Reference(settable) {
Security.Settable(settable)
return settable.optionalSettable.id
}
// The inverse of Reference: the ReactiveValue for a settable id. Throws on an
// unknown id (a programming error).
static Dereference(settableId) {
const fromSource = Reactive._sourceValueMap[settableId]
if (fromSource !== undefined) {
return fromSource
}
const fromDestination = Reactive._destinationValueMap[settableId]
if (fromDestination !== undefined) {
return fromDestination
}
throw new Error('Reactive.Dereference: settable id ' + settableId + ' not found')
}
// ---- lock / error readers -------------------------------------------------
static IsLocked(value) {
if (!(value instanceof ReactiveValue)) {
return false
}
return value.optionalGettable != null && value.optionalGettable.optionalLock != null
}
// Whether a value CAN be set at all: it carries a settable (a State or a reverse output, or
// a field that routes to one), as opposed to a read-only value (a literal, a Calculate, a
// gathered result). This is the first-class settability check, so read-only and settable
// values can be mixed into any object fearlessly and a driver can ask "can I set this?"
// without ever seeing an id. A control bound to a value that cannot be set is disabled, just
// as one bound to a write-locked value is (see IsWriteLocked).
static CanSet(value) {
return value instanceof ReactiveValue && value.optionalSettable != null
}
// The WRITE lock: true when the value must not be SET right now (an ongoing reverse
// process reads or writes it). It can still be read/displayed. A settable control
// bound to a write-locked value is disabled by the driver.
static IsWriteLocked(value) {
if (!(value instanceof ReactiveValue)) {
return false
}
return value.optionalGettable != null && value.optionalGettable.optionalWriteLock != null
}
static HasError(value) {
if (!(value instanceof ReactiveValue)) {
return false
}
return value.optionalGettable != null
&& value.optionalGettable.optionalLock != null
&& lockFirstError(value.optionalGettable.optionalLock) != null
}
static OptionalErrorMessage(value) {
if (!(value instanceof ReactiveValue)) {
return undefined
}
const optionalLock = value.optionalGettable == null ? undefined : value.optionalGettable.optionalLock
const optionalError = optionalLock == null ? undefined : lockFirstError(optionalLock)
return optionalError == null ? undefined : optionalError.message
}
static OptionalRetryFlag(value) {
if (!(value instanceof ReactiveValue)) {
return undefined
}
const optionalLock = value.optionalGettable == null ? undefined : value.optionalGettable.optionalLock
const optionalError = optionalLock == null ? undefined : lockFirstError(optionalLock)
const optionalRetryFlagId = optionalError == null ? undefined : optionalError.optionalRetryFlagId
if (optionalRetryFlagId == null) {
return undefined
}
return Reactive.Dereference(optionalRetryFlagId)
}
static HasRetryFlag(value) {
return Reactive.OptionalRetryFlag(value) !== undefined
}
// Return value locked with a fresh error appended.
static AddError(value, errorMessage, optionalRetryFlagId) {
Security.Gettable(value)
const reactiveValue = Reactive.Create(value)
const newError = new ReactiveError(errorMessage, optionalRetryFlagId)
const newLock = lockLeaf(new ReactiveLock(newError))
const newGettable = new ReactiveGettable(newLock, reactiveValue.optionalGettable.value)
return new ReactiveValue(newGettable, reactiveValue.optionalSettable)
}
// Conditionally lock a value (loading state) when locked is truthy.
static Lock(value, locked) {
Security.Gettable(value)
Security.Gettable(locked)
const nativeValue = value instanceof ReactiveValue ? (value.optionalGettable == null ? undefined : value.optionalGettable.value) : value
const nativeLocked = locked instanceof ReactiveValue ? (locked.optionalGettable == null ? undefined : locked.optionalGettable.value) : locked
const optionalLock = nativeLocked ? lockLeaf(new ReactiveLock(undefined)) : undefined
const gettable = new ReactiveGettable(optionalLock, nativeValue)
return new ReactiveValue(gettable, undefined)
}
// Return value with lockSource's lock merged in (error beats loading). Preserves
// value's settability.
static WithLockFrom(value, lockSource) {
Security.Gettable(value)
Security.Gettable(lockSource)
const sourceLock = lockSource instanceof ReactiveValue && lockSource.optionalGettable != null
? lockSource.optionalGettable.optionalLock
: undefined
if (sourceLock == null) {
return value
}
const reactiveValue = Reactive.Create(value)
const existingLock = reactiveValue.optionalGettable == null ? undefined : reactiveValue.optionalGettable.optionalLock
// Union the two lock trees (a direct error beats a direct loading lock, handled in lockMerge).
const mergedLock = lockMerge(existingLock, sourceLock)
const nativeValue = reactiveValue.optionalGettable.value
const gettable = new ReactiveGettable(mergedLock, nativeValue)
return new ReactiveValue(gettable, reactiveValue.optionalSettable)
}
// ---- private: updates split ----------------------------------------------
// Updates targeting settables with NO reverse callback (plain state writes).
static _getStateUpdates(updatesMap, programValues) {
const result = {}
for (const key of Object.keys(updatesMap)) {
const update = updatesMap[key]
const settableId = parseInt(key, 10)
if (update.optionalSettable == null) {
continue
}
const updateHasCallback = update.optionalSettable.optionalReverseCallback != null
const originalValue = programValues[settableId]
const originalHasCallback = originalValue != null && originalValue.optionalSettable != null
&& originalValue.optionalSettable.optionalReverseCallback != null
if (!updateHasCallback && !originalHasCallback) {
result[key] = update
}
}
return result
}
// Updates targeting settables WITH a reverse callback (reverse triggers). If only
// the original has the callback, rebuild the update carrying it.
static _getReverseUpdates(updatesMap, programValues) {
const result = {}
for (const key of Object.keys(updatesMap)) {
const update = updatesMap[key]
const settableId = parseInt(key, 10)
if (update.optionalSettable == null) {
continue
}
const updateHasCallback = update.optionalSettable.optionalReverseCallback != null
const originalValue = programValues[settableId]
const originalSettable = originalValue == null ? null : originalValue.optionalSettable
const originalHasCallback = originalSettable != null && originalSettable.optionalReverseCallback != null
if (updateHasCallback) {
result[key] = update
} else if (originalHasCallback) {
const settableWithCallback = new ReactiveSettable(settableId, originalSettable.optionalReverseCallback)
result[key] = new ReactiveValue(update.optionalGettable, settableWithCallback)
}
}
return result
}
static _mergeUpdates(destinationMap, stateUpdates) {
const result = Object.assign({}, destinationMap)
for (const key of Object.keys(stateUpdates)) {
const update = stateUpdates[key]
Security.Settable(update)
result[key] = update
}
return result
}
// ---- private: reverse processes ------------------------------------------
static _createReverseProcesses(reverseUpdates) {
const newReverseProcesses = {}
for (const key of Object.keys(reverseUpdates)) {
const settableId = parseInt(key, 10)
newReverseProcesses[settableId] = Reactive._createReverseProcess(settableId, reverseUpdates[key])
}
return newReverseProcesses
}
static _createReverseProcess(settableId, update) {
Security.Reverse(update)
const reverseCallback = update.optionalSettable.optionalReverseCallback
const targetValue = update.optionalGettable.value
const callback = () => reverseCallback(targetValue)
return new ReactiveProcess(callback, [])
}
static _runReverseProcesses(reverseProcesses) {
const unfinished = {}
for (const key of Object.keys(reverseProcesses)) {
const settableId = parseInt(key, 10)
const optionalUnfinishedProcess = Reactive._runReverseProcess(settableId, reverseProcesses[key])
if (optionalUnfinishedProcess != null) {
unfinished[settableId] = optionalUnfinishedProcess
}
}
return unfinished
}
// Run one reverse process: run its callback (which fans out Sets), collect ONLY
// the State targets actually Set, combine their locks, and apply that lock to just
// those targets. If locked, return the process as unfinished for the next run.
static _runReverseProcess(settableId, process) {
const previousDestinationValueMap = Reactive._destinationValueMap
const previousReverseSetTargets = Reactive._reverseSetTargets
const previousReverseSetPaths = Reactive._reverseSetPaths
Reactive._destinationValueMap = {}
Reactive._reverseSetTargets = []
Reactive._reverseSetPaths = {}
let setTargetIds = []
let setTargetValues = []
let reverseReadValues = []
const previousReverseReadValues = Reactive._reverseReadValues
const updatedScopeItems = Reactive._runCallback(process.rootScopeItems, () => {
Reactive._reverseReadValues = []
process.callback()
reverseReadValues = Reactive._reverseReadValues
Reactive._reverseReadValues = previousReverseReadValues
setTargetIds = Reactive._reverseSetTargets.slice()
for (const id of setTargetIds) {
const value = Reactive._destinationValueMap[id]
if (value != null) {
setTargetValues.push(value)
}
}
})
const setPaths = Reactive._reverseSetPaths
Object.assign(previousDestinationValueMap, Reactive._destinationValueMap)
Reactive._destinationValueMap = previousDestinationValueMap
Reactive._reverseSetTargets = previousReverseSetTargets
Reactive._reverseSetPaths = previousReverseSetPaths
if (setTargetIds.length === 0) {
return undefined
}
// Unfinished iff a Set value is still locked (loading/error): the reverse cannot settle
// and re-runs next cycle. While unfinished, the states it wrote are WRITE-locked so
// nothing else changes them mid-flight; on the completing run the write lock lifts.
let unfinished = false
for (const value of setTargetValues) {
if (value.optionalGettable != null && value.optionalGettable.optionalLock != null) {
unfinished = true
}
}
// A whole-value write lock for read-dependencies (a reverse reads a whole state leaf).
const readDepWriteLock = unfinished ? lockLeaf(new ReactiveLock(undefined)) : undefined
for (const id of setTargetIds) {
const update = Reactive._destinationValueMap[id]
if (update == null) {
continue
}
const optionalExisting = Reactive._sourceValueMap[id]
const existingHasGettable = optionalExisting != null && optionalExisting.optionalGettable != null
const hasGettable = existingHasGettable || update.optionalGettable != null
const nativeValue = update.optionalGettable != null
? update.optionalGettable.value
: (existingHasGettable ? optionalExisting.optionalGettable.value : undefined)
// READ lock: keep the target's OWN (already path-scoped) lock, so a field set to a
// loading value spins on that field alone and its siblings stay live.
const readTree = update.optionalGettable != null ? update.optionalGettable.optionalLock : undefined
// WRITE lock: while unfinished, lock EXACTLY the field paths this reverse wrote
// (root..leaf), so editing a sibling field of the same state stays enabled.
const writeTree = unfinished ? Reactive._writeTreeForPaths(setPaths[id]) : undefined
const optionalGettable = hasGettable ? new ReactiveGettable(readTree, nativeValue, writeTree) : undefined
const settable = new ReactiveSettable(id, undefined)
const valueToSet = new ReactiveValue(optionalGettable, settable)
Reactive._sourceValueMap[id] = valueToSet
Reactive._destinationValueMap[id] = valueToSet
}
// Write-lock the STABLE state leaves this reverse READ from its closure, so nothing
// else changes its inputs while it runs (and the controls that set them disable).
// Skips: its own Set targets (locked above); non-state reads; and states that are
// themselves READ-locked (loading) - those are async inputs a driver must still fill,
// so write-locking them would deadlock. The write lock is present while unfinished and
// cleared (undefined) on the completing run, so it lifts when the process finishes.
const seenReadIds = {}
for (const readValue of reverseReadValues) {
if (!(readValue instanceof ReactiveValue) || readValue.optionalSettable == null) {
continue
}
if (readValue.optionalSettable.optionalReverseCallback != null) {
continue
}
const readId = readValue.optionalSettable.id
if (seenReadIds[readId] === true || setTargetIds.indexOf(readId) >= 0) {
continue
}
seenReadIds[readId] = true
const existingRead = Reactive._sourceValueMap[readId]
if (existingRead == null || existingRead.optionalGettable == null || existingRead.optionalGettable.optionalLock != null) {
continue
}
const readGettable = existingRead.optionalGettable
const relockedGettable = new ReactiveGettable(readGettable.optionalLock, readGettable.value, readDepWriteLock)
const relockedValue = new ReactiveValue(relockedGettable, new ReactiveSettable(readId, undefined))
Reactive._sourceValueMap[readId] = relockedValue
Reactive._destinationValueMap[readId] = relockedValue
}
if (!unfinished) {
return undefined
}
return new ReactiveProcess(process.callback, updatedScopeItems)
}
// The WRITE-lock tree for the field paths a reverse wrote to one state: a lock at each
// path (root..leaf), merged. No paths, or a whole-value ([]) set, locks the whole value.
static _writeTreeForPaths(paths) {
if (paths == null || paths.length === 0) {
return lockLeaf(new ReactiveLock(undefined))
}
let tree = null
for (const path of paths) {
const leaf = lockScope(path, lockLeaf(new ReactiveLock(undefined)))
tree = lockMerge(tree, leaf)
}
return tree
}
// ---- private: lock/error combination -------------------------------------
static _getLocks(values) {
const locks = []
for (const value of values) {
const optionalLock = value.optionalGettable == null ? undefined : value.optionalGettable.optionalLock
if (optionalLock != null) {
locks.push(optionalLock)
}
}
return locks
}
static _getErrors(trees) {
const errors = []
for (const tree of trees) {
lockAllErrors(tree, errors)
}
return errors
}
static _getRetryFlagIds(errors) {
const retryFlagIds = []
for (const error of errors) {
if (error.optionalRetryFlagId != null) {
retryFlagIds.push(error.optionalRetryFlagId)
}
}
return retryFlagIds
}
// A combined retry flag that, when Set, triggers every upstream retry flag.
static _generateCombinedRetryFlagId(retryFlagIds) {
const retryId = Reactive.StableId()
const retrySettable = new ReactiveSettable(retryId, (newValue) => {
for (const flagId of retryFlagIds) {
const flag = Reactive.Dereference(flagId)
Reactive.Set(flag, true)
}
})
const combinedRetryFlag = new ReactiveValue(new ReactiveGettable(undefined, false), retrySettable)
Reactive._destinationValueMap[retryId] = combinedRetryFlag
return retryId
}
// ---- private: scope machinery ---------------------------------------------
static _nextSourceScopeItem() {
const sourceScopeItems = Reactive._sourceScopeItems
const destinationScopeItems = Reactive._destinationScopeItems
const index = destinationScopeItems.length
if (index < sourceScopeItems.length) {
return sourceScopeItems[index]
}
const id = Reactive._nextIdCounter++
return new ReactiveScopeItem(id, {})
}
static _runCallback(sourceScopeItems, callback) {
const previousSourceScopeItems = Reactive._sourceScopeItems
const previousDestinationScopeItems = Reactive._destinationScopeItems
Reactive._sourceScopeItems = sourceScopeItems
Reactive._destinationScopeItems = []
Reactive._lastCallbackResult = callback()
const updatedScopeItems = Reactive._destinationScopeItems
Reactive._sourceScopeItems = previousSourceScopeItems
Reactive._destinationScopeItems = previousDestinationScopeItems
return updatedScopeItems
}
}
Section 3 - reification registry (net-new)
// ==========================================================================
// Section 3 - reification registry (net-new)
// ==========================================================================
//
// Module/Function/Type/Union/Field/Variant build a tree of real JS objects on the
// global scope so authored code can write Content.Text(...), Clip.Param.Payload.Union,
// Input.Dropdown.Option(...) directly. Modules MERGE across files (a namespace may be
// declared in many files). A Function is also a namespace object (a pseudo-module),
// so Input.Dropdown can be a function AND the home of Input.Dropdown.Option. A Type is a
// descriptor object that is ALSO a namespace node (types can host static functions).
// The container currently open for Module/Function/Type/Union declarations. The root
// is the global object, so top-level modules become global names.
const globalScope = typeof globalThis !== 'undefined' ? globalThis : this
const namespaceStack = [globalScope]
// The type descriptor currently open for Field/Variant declarations.
const typeStack = []
// The dotted module path currently being DECLARED (load time), so a function can
// record which module it belongs to. Channel names are scoped to that module.
const modulePathStack = []
// The module path of the function currently EXECUTING, and the stack of executing
// functions (for registering nested pseudo-module functions on the parent for the
// duration of its call). Both are runtime, driven by the Function wrapper.
const runtimeModuleStack = []
// Containers (a function object or a runtime module object) that nested runtime
// declarations register ON. Top = where to attach the next nested Module/Function.
const runtimeContainerStack = []
// The stack of executing FUNCTIONS; each carries the restorations that undo the
// nested declarations it introduced, run when that function returns.
const runtimeFunctionStack = []
// The stack of VALUE-ARGUMENT frames, one per executing function: { values, index }.
// Reactive.Argument pulls the next value off the top frame (positional, type-checked).
const argumentsStack = []
// Registered drivers: qualified channel name -> factory(params, addUpdates).
const driverRegistry = {}
// Channels whose driver is ALWAYS active (no Mount config needed) - core paramless drivers
// like 'calculate' that every program relies on. Mount activates these automatically.
const alwaysActiveDrivers = {}
// ---- interfaces / modes / With: the interface-binding environment (doc 14) ----
//
// An interface (Reactive.Interface) is an abstract shape; an implementation is a namespace
// that provides its members; Reactive.With binds interface -> implementation for a scope a
// Reactive.Mode establishes. A member CALL dispatches to the currently bound implementation.
//
// Two environments, deliberately asymmetric (doc 14 sec 4):
// - userAmbientStack: the environments USER CODE and SLOTS resolve against. Reactive.Mode
// pushes a fresh one (empty = all interfaces reset); Reactive.With fills it; the base
// env (index 0) holds the root defaults. Each env keeps __parent for pass-through.
// - resolveAmbient: the environment interface calls resolve against RIGHT NOW. It is the
// mode env while user code runs, and an implementation's captured home while its body
// runs. A Reactive.Function snapshots resolveAmbient as its __home at definition; the
// dispatcher runs a member under that home; a Slot runs the caller's builder back under
// the user ambient (so the mode retargets user code, implementations run at home).
const baseAmbient = { __parent: null }
const userAmbientStack = [baseAmbient]
let resolveAmbient = baseAmbient
let nextInterfaceId = 1
// While declaring inside a Reactive.Interface body: { root, path } (root interface object +
// the member path being built), so a nested Module extends the path and a Function becomes
// a path-aware dispatcher instead of a runnable body.
const interfaceStack = []
// While RECORDING an interface member's signature: { args, returns }. An interface member's
// body is run in this mode so Reactive.Argument / Reactive.Slot / Reactive.Returns record the
// signature (for conformance checking + the future static pass) instead of pulling values.
const signatureRecordingStack = []
// Look up the implementation bound for an interface in the current resolveAmbient. A Mode's
// env is a clean slate (the seal), so this does NOT chain to __parent - only explicit With
// (including pass-through, which copies the parent binding in) populates an env. Returns
// null when unbound.
function resolveImplementation(interfaceId) {
const env = resolveAmbient
return (env != null && Object.prototype.hasOwnProperty.call(env, interfaceId)) ? env[interfaceId] : null
}
// Conformance: the interface member paths an implementation does NOT provide as a callable
// (empty = fully conformant). Used to check an implementation at Reactive.With time so a gap
// is reported at the bind site, not lazily when the missing member is first dispatched.
function implementationGaps(interfaceObject, implementation) {
const members = interfaceObject.__interfaceMembers == null ? [] : interfaceObject.__interfaceMembers
const missing = []
for (let i = 0; i < members.length; i++) {
const path = members[i].path
let target = implementation
for (let j = 0; j < path.length; j++) {
target = target == null ? null : target[path[j]]
}
if (typeof target !== 'function') {
missing.push(path.join('.'))
}
}
return missing
}
// A path-aware dispatcher for one interface member. Calling it forwards to the current
// implementation's same member, run under that member's home (doc 14 sec 2/4).
function makeInterfaceDispatcher(rootInterface, memberPath) {
return function () {
const impl = resolveImplementation(rootInterface.__interfaceId)
if (impl == null) {
throw new Error('Reactive: no implementation of interface "' + rootInterface.__interfaceName + '" in scope (member .' + memberPath.join('.') + '). Bind it with Reactive.With inside a Reactive.Mode.')
}
let target = impl
for (let i = 0; i < memberPath.length; i++) {
target = target == null ? null : target[memberPath[i]]
}
if (typeof target !== 'function') {
throw new Error('Reactive: implementation of "' + rootInterface.__interfaceName + '" is missing member .' + memberPath.join('.'))
}
const saved = resolveAmbient
resolveAmbient = target.__home != null ? target.__home : saved
try {
return target.apply(null, arguments)
} finally {
resolveAmbient = saved
}
}
}
function currentContainer() {
return namespaceStack[namespaceStack.length - 1]
}
// Register `value` on `container[name]` for the duration of the nearest enclosing
// function call, restoring the previous binding when that function returns. This is
// how a pseudo-module declaration (an inner Function or Module) exists only while its
// defining function runs, and re-entrantly.
function registerTemporarily(container, name, value) {
const had = Object.prototype.hasOwnProperty.call(container, name)
const previous = had ? container[name] : undefined
container[name] = value
const entry = runtimeFunctionStack[runtimeFunctionStack.length - 1]
entry.restorations.push(() => {
if (had) {
container[name] = previous
} else {
delete container[name]
}
})
}
function currentModulePath() {
return modulePathStack.join('.')
}
// Qualify a short channel name with the module of the currently executing function,
// so 'content' inside a Dom function is 'Dom.content' - channels are module-scoped
// and cannot collide across modules.
// Channels are FRAME-SCOPED bare labels (doc 14 sec 6): a channel name is used as-is, and
// routing is by the emission-frame stack - an emission is caught by the nearest enclosing
// Gather of that name, or, if none, by the root Driver of that name. No module prefix, so a
// Gather in one module catches an Emit in another (e.g. a Region gathering 'items' catches a
// sub-module implementation's 'items'). Kept as a function so the one call site is obvious.
function qualifyChannel(name) {
return name
}
function currentType() {
if (typeStack.length === 0) {
throw new Error('Reactive.Field/Variant called outside a Reactive.Type/Union body')
}
return typeStack[typeStack.length - 1]
}
// A type descriptor is stashed on a namespace object under this key, so the object
// doubles as a usable type reference and a namespace node.
const TYPE_KEY = '__reactiveType'
Reactive.Module = function (name, body) {
if (runtimeFunctionStack.length > 0) {
// Runtime (nested) module - e.g. Dom.Event declared inside the Dom scope's body.
// A fresh namespace registered on the current container for the enclosing
// function's call; its inner functions attach to it. It carries no module path
// (channel scoping uses the enclosing function's module).
const container = runtimeContainerStack[runtimeContainerStack.length - 1]
const moduleObject = {}
registerTemporarily(container, name, moduleObject)
runtimeContainerStack.push(moduleObject)
try {
body()
} finally {
runtimeContainerStack.pop()
}
return moduleObject
}
// Load-time module: register on the namespace permanently and track the path.
const container = currentContainer()
const existing = container[name]
const namespaceObject = existing != null ? existing : {}
container[name] = namespaceObject
namespaceStack.push(namespaceObject)
modulePathStack.push(name)
// Inside a Reactive.Interface body, a nested module extends the member path so its
// functions dispatch as <interface>.<...>.<member> (e.g. Maths.Integer.Add).
const inInterface = interfaceStack.length > 0
if (inInterface) {
interfaceStack[interfaceStack.length - 1].path.push(name)
}
try {
body()
} finally {
if (inInterface) {
interfaceStack[interfaceStack.length - 1].path.pop()
}
modulePathStack.pop()
namespaceStack.pop()
}
return namespaceObject
}
// A function is callable AND a namespace (a pseudo-module). The definition closure's
// PARAMETERS are the function's GENERIC type parameters (arity = number of generics);
// VALUE arguments are declared inside the body with Reactive.Argument (positional,
// typed). So a non-generic function (arity 0) is called Fn(...values); a generic one
// Fn(...types)(...values) - curried, explicit, no inference. Each body run sits in its
// own emission frame + arguments frame and records its module (for channel scoping); a
// Reactive.Function declared INSIDE a running body is a nested pseudo-module function,
// registered on the enclosing function for that call only (re-entrant), capturing the
// enclosing call's locals by closure - the sole legal place to Emit into its Gather.
Reactive.Function = function (name, impl) {
// Declared inside a Reactive.Interface body: a SIGNATURE, realised as a path-aware
// DISPATCHER. Calling it looks up the current implementation of the root interface and
// forwards to the same member, run under that member's home (doc 14 sec 2/4). The impl
// closure is kept for the future static pass but is NOT run at dispatch.
if (interfaceStack.length > 0) {
const frame = interfaceStack[interfaceStack.length - 1]
const memberPath = frame.path.concat([name])
// Record the member's signature by running its closure in signature mode (Argument /
// Slot / Returns record instead of pulling). Signature closures are pure declarations.
const signature = { args: [], returns: undefined }
signatureRecordingStack.push(signature)
try {
impl()
} finally {
signatureRecordingStack.pop()
}
const dispatcher = makeInterfaceDispatcher(frame.root, memberPath)
dispatcher.__reactiveSignature = signature
if (frame.root.__interfaceMembers == null) {
frame.root.__interfaceMembers = []
}
frame.root.__interfaceMembers.push({ path: memberPath, signature: signature })
currentContainer()[name] = dispatcher
return dispatcher
}
const runtime = runtimeFunctionStack.length > 0
const definitionModulePath = runtime
? (runtimeModuleStack.length > 0 ? runtimeModuleStack[runtimeModuleStack.length - 1] : '')
: currentModulePath()
const genericArity = impl.length
function runBody(typeArgs, valueArgs) {
// Type-check mode: branch coverage forces recursive branches that real data would stop, so
// bound the traversal by DEPTH (not function identity - scope functions like Dom re-enter
// themselves legitimately for every nested tag). An over-deep call returns a permissive
// placeholder so nothing crashes and the pass terminates.
if (Reactive._typeChecking && Reactive._typeCheckDepth >= Reactive._typeCheckDepthLimit) {
return typeCheckPlaceholder
}
if (Reactive._typeChecking) {
Reactive._typeCheckDepth++
}
Reactive._emissionFrameStack.push({})
runtimeModuleStack.push(fn.__modulePath)
runtimeContainerStack.push(fn)
const functionEntry = { restorations: [] }
runtimeFunctionStack.push(functionEntry)
argumentsStack.push({ values: valueArgs, index: 0 })
try {
const result = impl.apply(null, typeArgs)
// Enforce a declared return type (Reactive.Returns in the body). A result already
// carrying its type is trusted; anything else is validated against the return type.
if (functionEntry.returnType != null && result != null && result[TYPE_SYMBOL] == null) {
const returnDescriptor = Reactive.DescriptorOf(functionEntry.returnType)
validateAgainstDescriptor(result, returnDescriptor, (fn.__modulePath === '' ? '' : fn.__modulePath + '.') + name + ' return')
}
return result
} finally {
if (Reactive._typeChecking) {
Reactive._typeCheckDepth--
}
argumentsStack.pop()
runtimeFunctionStack.pop()
for (let i = functionEntry.restorations.length - 1; i >= 0; i--) {
functionEntry.restorations[i]()
}
runtimeContainerStack.pop()
runtimeModuleStack.pop()
const frame = Reactive._emissionFrameStack.pop()
// Bubble ungathered channels to the LEXICAL parent frame (closure semantics), or to the
// root frame for a top-level function (drivers). NOT the dynamic caller.
const rootFrame = Reactive._emissionFrameStack.length > 0 ? Reactive._emissionFrameStack[0] : null
const parent = fn.__lexicalParentFrame != null ? fn.__lexicalParentFrame : rootFrame
if (parent != null && parent !== frame) {
for (const channel of Object.keys(frame)) {
if (frame[channel].length > 0) {
parent[channel] = (parent[channel] == null ? [] : parent[channel]).concat(frame[channel])
}
}
}
}
}
const fn = function () {
if (genericArity === 0) {
return runBody([], Array.prototype.slice.call(arguments))
}
const typeArgs = Array.prototype.slice.call(arguments)
return function () {
return runBody(typeArgs, Array.prototype.slice.call(arguments))
}
}
fn.__reactiveImpl = impl
fn.__modulePath = definitionModulePath
fn.__genericArity = genericArity
// Home = the interface environment visible where this function is DEFINED. An
// implementation resolves its own interface calls against this, so it reaches the layer
// below the mode it is later installed into (doc 14 sec 4).
fn.__home = resolveAmbient
// Lexical parent frame (channels are closures, doc 14 sec 6): the emission frame of the
// activation this function is DEFINED inside (its enclosing pseudo-module). Its emissions
// bubble to THIS captured frame - the exact one from its defining context, not the nearest
// gather on the dynamic call stack. A top-level function has no defining activation, so it
// bubbles to the root (where drivers gather).
fn.__lexicalParentFrame = runtime ? Reactive._emissionFrameStack[Reactive._emissionFrameStack.length - 1] : null
if (runtime) {
const container = runtimeContainerStack[runtimeContainerStack.length - 1]
registerTemporarily(container, name, fn)
return fn
}
const container = currentContainer()
const existing = container[name]
if (existing != null && typeof existing === 'object') {
Object.assign(fn, existing)
}
container[name] = fn
return fn
}
// Reactive.Argument(type) - declare and pull the next VALUE argument of the executing
// function, in order, type-checked. `type` may be a reified type, a generic type
// instantiated inline (Collection.Array(ElementType)), a Reactive.Callback signature,
// or Reactive.Any. Returns the value (a plain native, a binding, or a callback closure).
Reactive.Argument = function (type) {
if (signatureRecordingStack.length > 0) {
signatureRecordingStack[signatureRecordingStack.length - 1].args.push({ kind: 'value', type: type })
return undefined
}
if (argumentsStack.length === 0) {
throw new Error('Reactive.Argument called outside a reactive function body')
}
const frame = argumentsStack[argumentsStack.length - 1]
const value = frame.values[frame.index]
frame.index += 1
if (type != null && type.__reactiveCallback === true) {
if (typeof value !== 'function') {
throw new Error('Reactive.Argument expected a callback (function)')
}
return value
}
const descriptor = Reactive.DescriptorOf(type)
// Full structural validation (records, unions, arrays, primitives, deep). A value already
// carrying its type (a typed State, a dotted sub-binding) was validated at its source, so
// it is trusted; anything else (a plain native, a raw binding, a Calculate result) is
// validated here against the declared type, throwing a located Type error on a mismatch.
if (descriptor != null && value != null && value[TYPE_SYMBOL] == null) {
validateAgainstDescriptor(value, descriptor, descriptor.name)
}
// Apply the declared type: a record/union argument becomes a typed proxy so its fields
// and union options dot. A value arriving already typed (a typed State, a sub-binding
// dotted out of a parent) carries TYPE_SYMBOL and is returned untouched; a raw binding
// (e.g. an element handed in by Collection.Array.Each) is typed here.
if (descriptor != null && (descriptor.kind === 'record' || descriptor.kind === 'union')) {
if (value != null && value[TYPE_SYMBOL] == null) {
return Reactive._typed(value, descriptor)
}
}
return value
}
// Reactive.Callback(optionalReturnType, optionalInputsClosure) - a callback parameter's
// signature: its return type and a closure that declares its inputs via Reactive.Argument
// (used by the static pass; at runtime a callback is just invoked). Bare Reactive.Callback()
// is a void, no-input builder (the common component case: `contents`, `content`, `body`).
Reactive.Callback = function (optionalReturnType, optionalInputsClosure) {
return { __reactiveCallback: true, returnType: optionalReturnType, inputsClosure: optionalInputsClosure }
}
// Invoke a callback with explicit values bound as its Reactive.Argument inputs. A plain
// callback has no arguments frame of its own, so we push one for the call (used by
// Collection.Optional.If to hand a payload to its branch callback).
function invokeCallbackWithArguments(callback, values) {
argumentsStack.push({ values: values, index: 0 })
try {
return callback()
} finally {
argumentsStack.pop()
}
}
Interfaces / modes / With (doc 14)
// ==========================================================================
// Interfaces / modes / With (doc 14)
// ==========================================================================
// Declare an abstract interface: typed member signatures (a Reactive.Function inside becomes
// a dispatcher, not a runnable body) and abstract types, with no implementation. Like a
// Module in how it builds a namespace, but its functions dispatch to the currently bound
// implementation of this interface.
Reactive.Interface = function (name, body) {
const container = currentContainer()
const existing = container[name]
const interfaceObject = (existing != null && typeof existing === 'object') ? existing : {}
if (interfaceObject.__interfaceId == null) {
interfaceObject.__interfaceId = 'interface#' + (nextInterfaceId++)
}
interfaceObject.__interfaceName = currentModulePath() === '' ? name : currentModulePath() + '.' + name
container[name] = interfaceObject
namespaceStack.push(interfaceObject)
modulePathStack.push(name)
interfaceStack.push({ root: interfaceObject, path: [] })
try {
body()
} finally {
interfaceStack.pop()
modulePathStack.pop()
namespaceStack.pop()
}
return interfaceObject
}
// Reactive.Returns(type) - declare a member's return type inside its signature body (where
// generic type parameters are in scope). Records into the signature while an interface member
// is being recorded; otherwise a no-op (metadata for the static pass).
Reactive.Returns = function (type) {
if (signatureRecordingStack.length > 0) {
signatureRecordingStack[signatureRecordingStack.length - 1].returns = type
return
}
// In a running body: record the declared return type so runBody validates the result.
if (runtimeFunctionStack.length > 0) {
runtimeFunctionStack[runtimeFunctionStack.length - 1].returnType = type
}
}
// Reactive.Mode(body) - enter a mode. Reset all interface bindings (the seal), run body (a
// run of Reactive.With statements then the code that uses them), restore on exit. User code
// inside resolves interface calls against the mode's bindings.
Reactive.Mode = function (body) {
const parent = userAmbientStack[userAmbientStack.length - 1]
const env = { __parent: parent }
userAmbientStack.push(env)
const saved = resolveAmbient
resolveAmbient = env
try {
return body()
} finally {
resolveAmbient = saved
userAmbientStack.pop()
}
}
// Reactive.With(interface, implementation) - bind an interface to an implementation in the
// current mode (or the base environment, outside any mode). Reactive.With(interface) with no
// implementation is PASS-THROUGH: copy the implementation from the enclosing scope so it
// shines through the mode's reset.
Reactive.With = function (interfaceObject, implementation) {
if (interfaceObject == null || interfaceObject.__interfaceId == null) {
throw new Error('Reactive.With: first argument is not a Reactive.Interface')
}
const env = userAmbientStack[userAmbientStack.length - 1]
const id = interfaceObject.__interfaceId
if (arguments.length >= 2 && implementation !== undefined) {
const gaps = implementationGaps(interfaceObject, implementation)
if (gaps.length > 0) {
throw new Error('Reactive.With(' + interfaceObject.__interfaceName + '): implementation does not conform - missing member(s): ' + gaps.join(', '))
}
env[id] = implementation
return
}
let ancestor = env.__parent
while (ancestor != null && !Object.prototype.hasOwnProperty.call(ancestor, id)) {
ancestor = ancestor.__parent
}
if (ancestor == null) {
throw new Error('Reactive.With(' + interfaceObject.__interfaceName + '): pass-through, but no implementation is bound in the enclosing scope')
}
env[id] = ancestor[id]
}
// Reactive.Conforms(interface, implementation) - the interface member paths the implementation
// does not provide (empty array = conformant). The conformance/static check, exposed for
// tooling; Reactive.With calls it and throws on a gap.
Reactive.Conforms = function (interfaceObject, implementation) {
if (interfaceObject == null || interfaceObject.__interfaceId == null) {
throw new Error('Reactive.Conforms: first argument is not a Reactive.Interface')
}
return implementationGaps(interfaceObject, implementation)
}
// Reactive.Signatures(interface) - the recorded member signatures of an interface: a list of
// { path, signature:{ args:[{kind,type|returnType}], returns } }. For tooling / the static pass.
Reactive.Signatures = function (interfaceObject) {
if (interfaceObject == null || interfaceObject.__interfaceId == null) {
throw new Error('Reactive.Signatures: first argument is not a Reactive.Interface')
}
return interfaceObject.__interfaceMembers == null ? [] : interfaceObject.__interfaceMembers
}
// Reactive.Slot(returnType, optionalSignature) - declare a type-safe CALLBACK argument: the
// next value argument is the caller's callback, and `optionalSignature` is a closure that
// declares the callback's own inputs (Reactive.Argument / nested Reactive.Slot) and, with
// returnType, its shape. Returns a HANDLE the provider invokes with the callback's argument
// values: handle(v1, v2, ...). Invoking it (doc 14 sec 4/5):
// - runs the callback under the CURRENT mode (the user ambient at invoke time, not a
// captured one), so its interface calls hit the mode's implementations while the
// enclosing implementation keeps running at its home;
// - supplies the values BOTH as an arguments frame (so a callback that pulls them with
// Reactive.Argument gets them, the framework convention) AND as JS arguments (so an
// interface member / dispatcher passed point-free forwards them to its implementation).
// returnType / optionalSignature are metadata for the static pass; not enforced at runtime.
Reactive.Slot = function (returnType, optionalSignature) {
if (signatureRecordingStack.length > 0) {
signatureRecordingStack[signatureRecordingStack.length - 1].args.push({ kind: 'slot', returnType: returnType, signature: optionalSignature })
return function () { return undefined }
}
let provided
if (argumentsStack.length > 0) {
const frame = argumentsStack[argumentsStack.length - 1]
provided = frame.values[frame.index]
frame.index += 1
}
const callback = (typeof provided === 'function') ? provided : null
const handle = function () {
if (callback == null) {
return undefined
}
const values = Array.prototype.slice.call(arguments)
const saved = resolveAmbient
resolveAmbient = userAmbientStack[userAmbientStack.length - 1]
argumentsStack.push({ values: values, index: 0 })
try {
return callback.apply(null, values)
} finally {
argumentsStack.pop()
resolveAmbient = saved
}
}
handle.__slotReturnType = returnType
handle.__slotSignature = optionalSignature
return handle
}
function defineType(kind, name, body) {
const container = currentContainer()
const existing = container[name]
const descriptor = (existing != null && existing[TYPE_KEY] != null) ? existing[TYPE_KEY] : { kind: kind, name: name, fields: [] }
descriptor.kind = kind
// A RECORD type is ALSO its positional constructor: called with the field values in
// declaration order it returns a plain native object { field: value, ... } - no ids, no
// reactive wrapping (settability is added later by whatever holds it). So its carrier is
// a function; the constructor reads descriptor.fields at CALL time, so fields declared by
// body() below are all present. Unions and primitives are not positionally constructable,
// so their carrier stays a plain object. An already-created carrier (a re-opened type, or
// a primitive being given a Native body) is reused as-is.
let typeObject
if (existing != null) {
typeObject = existing
} else if (kind === 'record') {
typeObject = function () {
const native = {}
for (let i = 0; i < descriptor.fields.length; i++) {
native[descriptor.fields[i].name] = arguments[i]
}
return native
}
} else {
typeObject = {}
}
typeObject[TYPE_KEY] = descriptor
container[name] = typeObject
namespaceStack.push(typeObject)
modulePathStack.push(name)
typeStack.push(descriptor)
try {
body()
} finally {
typeStack.pop()
modulePathStack.pop()
namespaceStack.pop()
}
return typeObject
}
// A record type: a fixed set of named fields.
Reactive.Type = function (name, body) {
return defineType('record', name, body)
}
// A tagged union: all-optional fields where exactly one is present; the present
// field name is the tag.
Reactive.Union = function (name, body) {
return defineType('union', name, body)
}
Reactive.Field = function (name, typeRef) {
currentType().fields.push({ name: name, tag: undefined, type: typeRef })
}
Reactive.Variant = function (tag, typeRef) {
currentType().fields.push({ name: tag, tag: tag, type: typeRef })
}
// Read a type descriptor off a reified type reference (or null for a non-type).
Reactive.DescriptorOf = function (typeRef) {
// A type carrier is a plain object (union/primitive) OR a function (a record, which is
// also its positional constructor), so accept both; anything without TYPE_KEY is not a
// reified type (the JS global Boolean, a Collection.Array carrier, a plain value).
if (typeRef == null || (typeof typeRef !== 'object' && typeof typeRef !== 'function')) {
return null
}
return typeRef[TYPE_KEY] == null ? null : typeRef[TYPE_KEY]
}
// ---- primitives -----------------------------------------------------------
//
// Leaf type descriptors referenced bare as field types. Text and Maths.Real/Integer
// are predefined here (Maths.js notes Real/Integer are framework primitives; the
// Text module in Text.js MERGES its functions onto this Text primitive). There is NO
// Boolean primitive: a boolean is modelled as the tagged union Logic.Maybe, owned by
// the Logic module (not the core), so the JS global `Boolean` is never shadowed.
function definePrimitive(container, name) {
const existing = container[name]
const primitiveObject = existing != null ? existing : {}
if (primitiveObject[TYPE_KEY] == null) {
primitiveObject[TYPE_KEY] = { kind: 'primitive', name: name, fields: [] }
}
container[name] = primitiveObject
return primitiveObject
}
definePrimitive(globalScope, 'Text')
Reactive.Module('Maths', () => {
const real = definePrimitive(currentContainer(), 'Real')
real[TYPE_KEY].nativeValidator = function (v) {
if (typeof v !== 'number' || Number.isNaN(v)) {
throw new Error('Maths.Real must be a number, got ' + describeValue(v))
}
return v
}
const integer = definePrimitive(currentContainer(), 'Integer')
integer[TYPE_KEY].nativeValidator = function (v) {
if (typeof v !== 'number' || !Number.isInteger(v)) {
throw new Error('Maths.Integer must be an integer, got ' + describeValue(v))
}
return v
}
})
// A permissive leaf type: any value, no validation. For loosely-typed arguments (an
// attribute value, a settable of unspecified type) until they are given precise types.
Reactive.Any = definePrimitive({}, 'Any')
// A native type is a primitive/leaf whose values are plain natives validated (and
// optionally coerced) by a function. Called inside a Reactive.Type body (see Text.js:
// Reactive.Type("Text", () => Reactive.Native(text => ...))). Native FUNCTION impls
// are NOT written with this - a native function's body just uses Reactive.Calculate
// to derive its result (e.g. Text.Equal).
Reactive.Native = function (nativeValidator) {
if (typeStack.length === 0) {
throw new Error('Reactive.Native must be called inside a Reactive.Type/Union body')
}
const descriptor = currentType()
descriptor.kind = 'primitive'
descriptor.nativeValidator = nativeValidator
}
Section 4 - strict type validation
// ==========================================================================
// Section 4 - strict type validation
// ==========================================================================
//
// A reified descriptor is enforced when a value ENTERS the system (a typed State,
// or a Set on a typed binding). Primitives run their Reactive.Native validator;
// records check each field; unions check exactly one variant is present. Null /
// undefined field values pass (nullability is not yet carried on descriptors), so
// optional* fields may be absent. Nested validation is one pass over the tree.
// Private per-binding tag holding the binding's type descriptor, so a Set can find
// the type even for a leaf binding passed far from where it was typed. A Symbol so
// it never collides with a data field and is invisible to the program.
const TYPE_SYMBOL = Symbol('reactiveType')
// A permissive stand-in returned by an over-deep call during the type-check pass. Any property
// read yields an empty array, so a caller that expected a gathered array (e.g. Dom.Tag reading
// child.content / child.attributes) keeps working; calling it yields an empty array too.
const typeCheckPlaceholder = new Proxy(function () {}, {
get: function () { return [] },
apply: function () { return [] },
})
// Strict typing: on by default. A single switch so a production build can compile the
// checks out, or a debugging session can turn them off. When off, validateAgainstDescriptor
// is a pass-through.
let strictTypes = true
Reactive.StrictTypes = function (on) { strictTypes = on !== false }
// Validate `value` against a reified type descriptor, deeply: primitives (native validator),
// records (each present field), unions (exactly one variant, valid), arrays (each element vs
// the element type). Handles a value that is a ReactiveValue - it validates the native inside,
// but SKIPS a locked/loading value (its native is a placeholder) and a reverse sink (no
// gettable). Throws a Type error naming the path on a mismatch. Returns `value` unchanged.
function validateAgainstDescriptor(value, descriptor, path) {
if (descriptor == null || !strictTypes) {
return value
}
const here = path == null ? (descriptor.name || 'value') : path
// Reach the native behind a reactive value; a locked/loading value is skipped.
let native = value
if (value instanceof ReactiveValue) {
if (value.optionalGettable == null || value.optionalGettable.optionalLock != null) {
return value
}
native = value.optionalGettable.value
}
if (descriptor.kind === 'primitive') {
if (descriptor.nativeValidator != null && native != null) {
try {
descriptor.nativeValidator(native)
} catch (e) {
throw new Error('Type error at ' + here + ': ' + e.message)
}
}
return value
}
if (descriptor.kind === 'array') {
if (native == null || native.elements == null) {
throw new Error('Type error at ' + here + ': expected an array of ' + describeType(descriptor.elementType))
}
const elementDescriptor = Reactive.DescriptorOf(descriptor.elementType)
for (let i = 0; i < native.elements.length; i++) {
validateAgainstDescriptor(native.elements[i].value, elementDescriptor, here + '[' + i + ']')
}
return value
}
if (descriptor.kind === 'record') {
if (native == null || typeof native !== 'object') {
throw new Error('Type error at ' + here + ': expected a ' + descriptor.name + ' record object, got ' + describeValue(native))
}
for (const field of descriptor.fields) {
const fieldValue = native[field.name]
if (fieldValue !== undefined && fieldValue !== null) {
const fieldDescriptor = Reactive.DescriptorOf(field.type)
validateAgainstDescriptor(fieldValue, fieldDescriptor, here + '.' + field.name)
}
}
return value
}
if (descriptor.kind === 'union') {
if (native == null || typeof native !== 'object') {
throw new Error('Type error at ' + here + ': expected a ' + descriptor.name + ' union object, got ' + describeValue(native))
}
const presentTags = []
for (const field of descriptor.fields) {
if (native[field.name] !== undefined && native[field.name] !== null) {
presentTags.push(field.name)
}
}
if (presentTags.length !== 1) {
throw new Error('Type error at ' + here + ': a ' + descriptor.name + ' union needs exactly one variant, found [' + presentTags.join(', ') + ']')
}
const tag = presentTags[0]
const variant = descriptorField(descriptor, tag)
const variantDescriptor = Reactive.DescriptorOf(variant.type)
validateAgainstDescriptor(native[tag], variantDescriptor, here + '.' + tag)
return value
}
return value
}
// Small helpers for readable Type error messages.
function describeType(type) {
const d = Reactive.DescriptorOf(type)
return d != null && d.name != null ? d.name : 'value'
}
function describeValue(v) {
if (v === null) return 'null'
if (v === undefined) return 'undefined'
if (typeof v === 'string') return 'a string'
if (typeof v === 'number') return 'a number'
if (typeof v === 'boolean') return 'a boolean'
if (typeof v === 'function') return 'a function'
return 'a ' + typeof v
}
Section 5 - typed reversible accessors (the Proxy)
// ==========================================================================
// Section 5 - typed reversible accessors (the Proxy)
// ==========================================================================
//
// Reactive._typed(binding, descriptor) is PRIVATE (never called by application or library
// code, only by the engine: _makeState, Reactive.Argument, and the Proxy's own recursion).
// It wraps a binding so dotted paths (param.payload.vec3.x) stay two-way. A RECORD/UNION descriptor
// returns a Proxy over the (real) ReactiveValue: it still IS a ReactiveValue for the
// engine, but a field/variant access returns a fresh reversible sub-binding built
// with Reactive._objectField. A PRIMITIVE (leaf) needs no dotted access, so it is
// returned unproxied, only tagged with its descriptor for Set validation. The native
// data inside a binding is always PLAIN (no ids, no wrappers) - the settability lives
// on the binding, held privately, never reified into the program's data.
function descriptorField(descriptor, name) {
for (const field of descriptor.fields) {
if (field.name === name) {
return field
}
}
return undefined
}
// A reversible sub-binding for a RECORD field: read o[name]; write a shallow copy of
// o with name replaced (other fields preserved).
function recordFieldBinding(binding, name) {
return Reactive._objectField(
binding,
(o) => (o == null ? undefined : o[name]),
(o, v) => {
const base = o == null ? {} : o
const copy = Object.assign({}, base)
copy[name] = v
return copy
},
name
)
}
// A reversible sub-binding for a UNION variant: read o[tag]; write a NEW union that
// holds only this variant (so setting a variant's payload also fixes the tag, and
// switching variant is just setting a different one).
function unionVariantBinding(binding, tag) {
return Reactive._objectField(
binding,
(o) => (o == null ? undefined : o[tag]),
(o, v) => {
const replaced = {}
replaced[tag] = v
return replaced
},
tag
)
}
Reactive._typed = function (binding, descriptor) {
if (binding != null && typeof binding === 'object') {
binding[TYPE_SYMBOL] = descriptor
}
if (descriptor == null || descriptor.kind === 'primitive') {
return binding
}
const handler = {
get(target, prop, receiver) {
if (typeof prop !== 'string') {
return Reflect.get(target, prop, receiver)
}
const field = descriptorField(descriptor, prop)
if (field == null) {
return Reflect.get(target, prop, receiver)
}
const fieldDescriptor = Reactive.DescriptorOf(field.type)
const sub = descriptor.kind === 'union'
? unionVariantBinding(target, prop)
: recordFieldBinding(target, prop)
return Reactive._typed(sub, fieldDescriptor)
},
}
return new Proxy(binding, handler)
}
Section 6 - contexts: Emit / Gather (collect UP)
// ==========================================================================
// Section 6 - contexts: Emit / Gather (collect UP)
// ==========================================================================
//
// There is no dynamic provide/use and no closures-broken-free context: a value
// reaches deeper code only by ordinary closure capture. The ONE cross-scope channel
// is Emit/Gather, which collects values UP the call stack, scoped by pseudo-modules
// so it stays local and statically trackable:
//
// Reactive.Emit(channel, value) - contribute a value to `channel` in the current
// function's emission frame.
// Reactive.Gather(channel) - drain `channel` from the current frame and
// return the values (this scope and deeper).
//
// Every running Reactive.Function has an emission frame; on return, un-gathered
// channels bubble into the caller's frame. So a Gather placed after a builder collects
// everything emitted at that scope-or-deeper, and a value not caught keeps bubbling to
// the terminal Driver at the root (Section 7). Channel names are short and scoped to
// the emitting function's MODULE ('content' inside Dom is 'Dom.content').
//
// Legality (enforced by the static pass; for now an orphan at the root throws):
// - a PLAIN emit is legal only from a pseudo-module function whose defining function
// gathers the channel; it goes to that immediate gatherer, never further.
// - a RELAY (gather X then emit X in the SAME function) is legal only if a terminal
// exists above: a module Driver(X), or an enclosing function that gathers X. This
// is the recursion case (Tag gathers its children, then emits itself upward).
// A channel is TYPED: its type is the type of ONE element (the array a gather returns is a
// list of that type). Emit(type)('name', value) / Gather(type)('name') / EmitArray(type)
// ('name', values) are curried like every other generic (State(type)(value)). The type is
// enforced at BOTH ends: an emit validates its value, a gather validates every element it
// drains - so a wrongly-typed emission is caught the moment it is gathered, which the static
// pass (Reactive.Mount) surfaces before the program renders. The bare-string forms
// Emit('name', value) / Gather('name') remain (untyped = Reactive.Any, no check) so existing
// modules keep working during migration.
function currentFrameFor(op, channel) {
const frame = Reactive._emissionFrameStack[Reactive._emissionFrameStack.length - 1]
if (frame == null) {
throw new Error('Reactive.' + op + '("' + channel + '") called outside a run')
}
return frame
}
function pushEmission(channel, value, optionalType) {
const frame = currentFrameFor('Emit', channel)
const qualified = qualifyChannel(channel)
if (optionalType != null && strictTypes && value != null && value[TYPE_SYMBOL] == null) {
const descriptor = Reactive.DescriptorOf(optionalType)
if (descriptor != null) {
validateAgainstDescriptor(value, descriptor, 'Emit(' + qualified + ')')
}
}
if (frame[qualified] == null) {
frame[qualified] = []
}
frame[qualified].push(value)
}
function drainGather(channel, optionalType) {
const frame = currentFrameFor('Gather', channel)
const qualified = qualifyChannel(channel)
const gathered = frame[qualified] == null ? [] : frame[qualified]
frame[qualified] = []
if (optionalType != null && strictTypes) {
const descriptor = Reactive.DescriptorOf(optionalType)
if (descriptor != null) {
for (const element of gathered) {
if (element != null && element[TYPE_SYMBOL] == null) {
validateAgainstDescriptor(element, descriptor, 'Gather(' + qualified + ')')
}
}
}
}
return gathered
}
Reactive.Emit = function (typeOrChannel, optionalValue) {
if (typeof typeOrChannel === 'string') {
pushEmission(typeOrChannel, optionalValue, null)
return
}
const type = typeOrChannel
return function (channel, value) {
pushEmission(channel, value, type)
}
}
Reactive.EmitArray = function (typeOrChannel, optionalArray) {
if (typeof typeOrChannel === 'string') {
for (const element of optionalArray) {
pushEmission(typeOrChannel, element, null)
}
return
}
const type = typeOrChannel
return function (channel, values) {
if (!Array.isArray(values)) {
throw new Error('Reactive.EmitArray("' + channel + '") expects an array')
}
for (const element of values) {
pushEmission(channel, element, type)
}
}
}
Reactive.Gather = function (typeOrChannel) {
if (typeof typeOrChannel === 'string') {
return drainGather(typeOrChannel, null)
}
const type = typeOrChannel
return function (channel) {
return drainGather(channel, type)
}
}
Section 7 - drivers and the run loop
// ==========================================================================
// Section 7 - drivers and the run loop
// ==========================================================================
//
// A driver is a module-scoped TERMINAL gather: whatever a module's functions emit to
// a channel and no function catches, the driver receives. Reactive.Driver(channel,
// factory) registers it in the module. `factory(params, addUpdates)` runs ONCE at
// startup (params come from Mount), keeps its own state in closure vars, and returns
// the per-run function fed the latest emissions (or an object with a `run` method).
// Async work (a frame tick, a fetch, a DOM event) calls addUpdates(updates) to queue
// state updates and schedule the next run; a driver must NOT call addUpdates while
// processing emissions synchronously. Three shapes fit this one contract:
// - shared-resource (Animate): one rAF loop, per-id state, ticks -> addUpdates
// - independent (Network) : one fetch per id, resolve -> addUpdates
// - tree/root (Dom) : reconcile the emitted node tree into a root element,
// DOM events -> addUpdates; params carry the root
// element and any custom-component handlers.
// A driver is the ROOT gather for a bare channel name (frame-scoped channels, doc 14 sec 6):
// register under the bare name, not module-qualified. Driver channel names must be globally
// unique (content / request / real); a name that reaches the root uncaught goes to its driver.
Reactive.Driver = function (channel, factory, optionalAlwaysActive) {
driverRegistry[channel] = factory
if (optionalAlwaysActive === true) {
alwaysActiveDrivers[channel] = true
}
}
// The always-active 'calculate' driver. Every Reactive.Calculate emits its callback here; the
// driver runs each callback (Reactive._runCalculation combines the input locks), and writes the
// result into its settable - kept locked while its inputs are locked. A lastSignature-per-result
// guard stops re-runs once a result is stable (no infinite loop). Values that do not serialise
// fall back to a stable string so they are treated as unchanged rather than looping.
Reactive.Driver('calculate', (params, addUpdates) => {
const lastSignature = {}
return (operations) => {
const updates = {}
for (const operation of operations) {
const computed = Reactive._runCalculation(operation.callback)
const resultId = Reactive.Reference(operation.result)
let valueSignature
try {
valueSignature = operation.callback == null ? 'n' : JSON.stringify(computed.value)
} catch (e) {
valueSignature = String(computed.value)
}
const signature = (computed.optionalLock != null ? 'L:' : 'V:') + (valueSignature === undefined ? 'u' : valueSignature)
if (lastSignature[resultId] === signature) {
continue
}
lastSignature[resultId] = signature
const gettable = new ReactiveGettable(computed.optionalLock, computed.value)
const settable = new ReactiveSettable(resultId, undefined)
updates[resultId] = new ReactiveValue(gettable, settable)
}
if (Object.keys(updates).length > 0) {
addUpdates(updates)
}
}
}, true)
// Reactive.Mount(contentCallback, driverConfig): driverConfig is a map of qualified
// channel name -> params for that driver (e.g. { 'Dom.content': { root, components } }).
// Each named driver is created once; after every Run its channel is drained from the
// root emissions and handed to its per-run function. Returns { addUpdates }.
Reactive.Mount = function (contentCallback, driverConfig) {
const config = driverConfig == null ? {} : driverConfig
let program = null
let pendingUpdates = {}
let scheduled = false
function addUpdates(updates) {
Object.assign(pendingUpdates, updates)
schedule()
}
function schedule() {
if (scheduled) {
return
}
scheduled = true
Promise.resolve().then(() => {
scheduled = false
render()
})
}
const active = []
const activeChannels = {}
for (const configKey of Object.keys(config)) {
// Channels are bare (frame-scoped). A config key is normalised to its bare last segment,
// so both the new form ('content') and the legacy module-qualified form ('Dom.content')
// resolve to the same bare driver + root emissions.
const channel = configKey.indexOf('.') === -1 ? configKey : configKey.slice(configKey.lastIndexOf('.') + 1)
const entry = config[configKey]
// A driver is SWAPPABLE: `entry.driver`, if present, is a replacement factory that
// overrides the module-registered one for this channel. Same contract -
// factory(params, addUpdates) -> process(emissions) (or { run }). This is how a
// program's capabilities are controlled, how drivers are simulated, how the UI is
// captured instead of rendered, and (later) how output is projected to another host.
const optionalOverride = (entry != null && typeof entry === 'object') ? entry.driver : undefined
const factory = optionalOverride != null ? optionalOverride : driverRegistry[channel]
if (factory == null) {
throw new Error('Reactive.Mount: no driver registered or provided for channel "' + channel + '"')
}
const made = factory(entry, addUpdates)
const process = typeof made === 'function' ? made : made.run
active.push({ channel: channel, process: process })
activeChannels[channel] = true
}
// Activate the core always-on drivers (e.g. 'calculate') that every program needs and that
// take no params, unless the config already provided one.
for (const channel of Object.keys(alwaysActiveDrivers)) {
if (activeChannels[channel] !== true) {
const madeAlways = driverRegistry[channel]({}, addUpdates)
const processAlways = typeof madeAlways === 'function' ? madeAlways : madeAlways.run
active.push({ channel: channel, process: processAlways })
activeChannels[channel] = true
}
}
function render() {
if (program === null) {
program = Reactive.Program(contentCallback)
}
const updates = pendingUpdates
pendingUpdates = {}
const hasUpdates = Object.keys(updates).length > 0
program = Reactive.Run(program, hasUpdates ? updates : null)
const root = Reactive._rootEmissions
// ONE delivery path for every driver: it drains its channel from the root frame. A
// structural region (Dom, and later Scene/Audio) gathers its own subtree and RETURNS it
// as a { channel: [values] } map - e.g. { content: nodeList }. We fold that map into the
// root emissions here, so a region's output reaches its driver by exactly the same route
// as an ambient signal that its producer (Animate 'real', Network 'request') emitted to
// the root directly. No channel is privileged: 'content' is just a key, and a program may
// drive several targets at once (return { content: ..., scene: ... }).
const returned = Reactive._programResult
if (returned != null && typeof returned === 'object' && !Array.isArray(returned)) {
for (const channel of Object.keys(returned)) {
const values = returned[channel]
if (Array.isArray(values) && values.length > 0) {
root[channel] = (root[channel] == null ? [] : root[channel]).concat(values)
}
}
}
for (const driver of active) {
const emissions = root[driver.channel] == null ? [] : root[driver.channel]
driver.process(emissions)
}
// Orphan guard (until the static pass): an emission that reached the root with no
// driver to catch it is an unterminated relay - a bug, not a silent drop.
for (const channel of Object.keys(root)) {
if (activeChannels[channel] !== true && root[channel].length > 0) {
throw new Error('Reactive.Mount: orphan emissions on channel "' + channel + '" (no terminal driver)')
}
}
}
// STATIC TYPE-CHECK PASS. Before any driver sees output, run the whole forward program once
// in TYPE-CHECK MODE: every branch is followed (If bodies run regardless of condition, Each
// callbacks run once), reified functions are memoised by identity so recursion terminates,
// and typed Emit/Gather validate their values - so a channel whose emits do not match its
// gather's declared element type throws HERE, on ANY path, and the program never starts
// rendering. The pass's result is discarded (drivers are only invoked by render()). Because
// component bodies are runtime closures this is a whole-program validation RUN, not a
// source-level static analysis; a memoised call site is checked once, not per argument shape.
if (strictTypes) {
Reactive._typeChecking = true
Reactive._typeCheckDepth = 0
try {
const typeCheckProgram = Reactive.Program(contentCallback)
Reactive.Run(typeCheckProgram, null)
} finally {
Reactive._typeChecking = false
Reactive._typeCheckDepth = 0
}
}
render()
return { addUpdates: addUpdates }
}
Internal handle for the sibling driver modules (Dom.js) and headless checks.
// ==========================================================================
// Internal handle for the sibling driver modules (Dom.js) and headless checks.
// Not for application code.
// ==========================================================================
Reactive._internal = {
ReactiveValue: ReactiveValue,
ReactiveGettable: ReactiveGettable,
ReactiveSettable: ReactiveSettable,
ReactiveLock: ReactiveLock,
ReactiveError: ReactiveError,
ReactiveArray: ReactiveArray,
ReactiveArrayElement: ReactiveArrayElement,
Security: Security,
TYPE_SYMBOL: TYPE_SYMBOL,
validateAgainstDescriptor: validateAgainstDescriptor,
driverRegistry: driverRegistry,
invokeCallbackWithArguments: invokeCallbackWithArguments,
}
TODO (deferred): MUTABLE CONTEXT
// ==========================================================================
// TODO (deferred): MUTABLE CONTEXT
// ==========================================================================
// Everything above is write-once: Emit/Gather collect, closures read. We have no
// notion of a context VALUE that can be mutated and re-read (like a context that
// updates), nor of PEEKING at what has already been emitted into a gather to read its
// latest value. Nothing in the current surface (Dom tree, Select/Option, the editor
// panels, the Animate/Network/Dom drivers) needs it, so it is intentionally absent.
// If a future feature does, the likely shapes are either a mutable-value context or a
// gather-peek that returns the current accumulation without draining. Add here.
// ==========================================================================
globalScope.Reactive = Reactive
})()