Signals & reactivity

Signal, Computed, AsyncSignal, PersistentSignal — the reactive contract.

The keystone primitive is Signal<T> — a value plus a listener list. Everything reactive flows from it.

Signal

Signal<int> count{0};

count.listen([](int v){ std::cout << v; });
count.set(42);                              // listeners fire
count.update([](int v){ return v + 1; });   // listeners fire
int v = count.get();                        // no listener side effects

In .vel source, @state declarations with a plain initializer become Signal<T>:

#Counter
  @count = 0          // Signal<int>

The component constructor listens on count_ and marks itself dirty when it changes.

Computed

@doubled = $count * 2

velc detects that the right-hand side references another signal and lowers this to:

Computed<int> doubled_{ [this]{ return count_.get() * 2; } };

Computed<T> walks the closure’s dependency list once at construction, subscribes to each, and re-evaluates lazily on .get() after invalidation.

AsyncSignal

@users: std::vector<User> = await fetchUsers()

Becomes:

AsyncSignal<std::vector<User>> users_{
  std::async(std::launch::async, []{ return fetchUsers(); })
};

AsyncSignal<T> polls the future on every tick() and exposes four observable fields:

FieldTypeMeaning
.loadingboolFuture is still pending.
.readyboolFuture resolved successfully.
.valueTThe resolved value (only valid when .ready).
.errorstd::stringThe exception message, if any.

In .vel, you read those as $users.loading, $users.value, etc. Each one is itself a reactive cell — branches re-evaluate on transition.

PersistentSignal

@theme = persist("theme", "dark")

Becomes a PersistentSignal<std::string> that auto-syncs to a JSON-backed key-value store (see framework::Storage). The value is hydrated on construction; every set() writes through on the next idle moment.

Effects

~ syntax declares an effect:

~ $count =>
  notify::toast("count changed to {$count}")

The codegen walks the body, finds every $signal reference, and registers a listener on each. Cleanup is automatic when the component unmounts.

Frame-level damage tracking

A global atomic frameDirty flag, raised by any Widget::markDirty() call, controls whether the next frame runs at all. Reactive rebuilds set it. Animations re-arm it from their tick(). Static pages don’t, so the app sits in glfwWaitEventsTimeout at ~0 CPU until something actually changes.

This is not fine-grained reactivity — granularity is per-component, not per-DOM-node. The rebuild walks the component’s build() and reconciles against the previous widget tree. It’s coarse like React, not surgical like Solid. The trade-off is one cheap pass per dirty component instead of an observer graph touching every primitive.