Reactivity: Signals, Derived, Effects & the Reactive Graph
Chapter: Reactivity — Signals, Derived, Effects & the Reactive Graph
Section titled “Chapter: Reactivity — Signals, Derived, Effects & the Reactive Graph”> Status of this chapter: Mixed. The reactive core — signal/derived/effect declarations, the ~>/-> binding operators, batch/untrack, and the synchronous push-pull graph (WritableSignal/DerivedSignal/Effect, empty-subs guard, dynamic re-subscription, eager drain) — is Shipped. The signal NAME every MS interval form is Shipped. Everything in Proposed extensions (§9) is Proposed (net-new surface, not in language-surface.ts today).
> Cross-refs: This chapter owns the in-process reactive primitives and their .pts-family surface. The .pui runes bridge ($state/$derived/$effect, escape analysis, $effect.pre, HMR) is [→ ch:pui-component-model]; this chapter shows the bridge only where a desugaring requires it and defers its full rules there. Resource-tied and async producers (source/async signal/promiseSignal/from*/proxySignal-as-a-source) are [→ ch:sources-async-and-native]. Edge-triggered when/when not/when start/stop, defer, arena, and the resource()/onDispose lifecycle are [→ ch:effects-lifecycle-and-concurrency] — this chapter covers only steady-state signal/derived/effect. Purity of derived and pure computations is [→ ch:purity-and-determinism]. The keyword catalog this chapter conforms to is src/language-surface.ts [→ ch:overview-and-surfaces §3.1].
0. The over-time-shape pillar
Section titled “0. The over-time-shape pillar”A schema describes the shape of a value at rest; a signal describes that same value changing over time within one process [→ ch:overview-and-surfaces §0]. Reactivity is the verb that ties rest-shape to over-time-shape: it is the in-process member of the four-distances family (signal / sync / source) that shares one downstream convention — a reactive value is read, written, and observed — while the projection (in-process graph here; a reconciler for sync; a peek/subscribe/dispose adapter for sources) is chosen elsewhere.
This chapter specifies the synchronous, glass-floor reactive core: three declaration forms (signal, derived, effect), two binding operators (~>, ->), two scoping combinators (batch, untrack), one interval form (every), and the runtime graph they all lower onto. Two invariants frame the whole chapter:
- INV-react-1 (synchronous settle). Para’s core reactive graph settles synchronously and eagerly: when a write completes outside any batch, every effect transitively reachable from it has already re-run before the writing statement returns. There is no microtask defer, no scheduler tick, no “next frame” in the core (the
.puibridge schedules into the renderer’s$effect; scheduling annotations are §9.5, Proposed). This is what makes a.ptsreactive program reason like straight-line code. - INV-react-2 (acyclic graph). The dependency graph (signals/deriveds as nodes, effects/deriveds as subscribers) is a DAG. A
derived/effectwhose evaluation writes a signal it transitively reads is a cycle; the core does not detect it statically (it is a runtime hazard — see §6.4), and thepureobligation onderived(§2, [→ ch:purity-and-determinism]) is the discipline that keeps it acyclic.
> Glass floor (I1). Every form below lowers to plain calls into @lyku/para-signals (the .pts target) or to runes (the .pui target). The runtime is ~200 lines of readable JS (signal/derived/effect/batch/untrack + the three node classes); the lowering is a local rewrite. Nothing here is reflection-driven or any-degraded (I3). A developer can read the emitted const count = signal(0) and the WritableSignal it constructs.
1. signal — the writable reactive cell
Section titled “1. signal — the writable reactive cell”A signal is a writable container whose reads, inside a tracking context, subscribe the active observer and whose writes invalidate subscribers.
Grammar
Section titled “Grammar”SignalDecl ::= "signal" Ident TypeAnn? "=" Expr Interval? ";"TypeAnn ::= ":" TypeInterval ::= "every" Expr ⟨§7⟩Ident— the cell name. After lowering it binds to aWritableSignal<T>value in.pts; in.puiit binds to a plain reactive identifier (the.set/.getare hidden by the bridge) [→ ch:pui-component-model].TypeAnnis optional; when present it annotates the value typeT, not the cell type.signal count: int = 0means the cell holds anint; the lowering threadsTso the inferred result isWritableSignal<number>.Interval(every Expr) makes the cell interval-driven; specified in §7.
Lexical note (statement-position keyword detection). signal is recognized as the keyword only before NAME [=,;:!] (catalog signal-decl: \b(signal)\b(?=\s+[A-Za-z_$][\w$]*\s*[=,;:!])). A bare value use — const signal = makeSignal(), signal() as a call, obj.signal — is not rewritten; this is what keeps the surface a strict superset [→ ch:overview-and-surfaces §1.1]. One signal statement MAY declare comma-separated declarators (signal a = 1, b = 2;); each is lowered independently (this matters for .pui, where escape analysis is per-name — §1.4).
Static semantics
Section titled “Static semantics”- Binding structure.
signal NAME = EXPRintroducesNAMEinto the enclosing scope as an immutableconstbinding (the cell isconst; the value is mutable through.set). Re-declaringNAMEis a host-level redeclaration error. - Type inference. Absent
TypeAnn,Tis inferred fromEXPRexactly asconst x = EXPRwould infer in the host, then lifted:signal(EXPR): WritableSignal<typeof EXPR>. WithTypeAnn,Tis fixed andEXPRmust be assignable to it (a host assignability error otherwise — never a silentany, I3). - No reactive obligation on the RHS.
EXPRis evaluated once, eagerly, untracked at construction. Reads of other signals insideEXPRdo not create a dependency —signal x = y.get() + 1snapshotsyonce. (To tracky, usederived— §2. The Proposed bare-read sugar §9.1 does not change this: asignalRHS never subscribes.)
Dynamic semantics
Section titled “Dynamic semantics”The cell is a WritableSignal with _value and a _subs: Set of subscribers (effects and deriveds). Its operations:
get()— callstrack(this): if a tracking context is active (currentEffect ≠ null), addsthisto the observer’s_depsand the observer tothis._subs(bidirectional edge). Returns_value. Aget()with no active context is a plain read — no subscription (this is whatpeek/untrackexploit, §4, §8).peek()— returns_valuewith notrackcall. Always non-subscribing, regardless of context.set(v)—Object.is-guarded (§6.1): ifObject.is(v, _value), returns immediately (no notification). Otherwise storesv, then runs the empty-subs guard (§6.2): if_subs.size === 0, returns (the value is stored; later reads observe it; there is nothing to invalidate). Otherwise enters a batch (batchDepth++), invalidates a snapshot of_subs(Array.from(this._subs)— so re-subscription during invalidation doesn’t mutate the iteration), thenbatchDepth--and, at depth 0,drain()s the effect queue (§3).update(fn)—set(fn(_value)); sugar for read-modify-write. (updateis a runtime method, not Para surface; reachable from the glass floor.)
> INV-react-3 (write is settle). A set() at batchDepth === 0 with non-empty subs returns only after the synchronous drain completes (INV-react-1). Inside a batch/during an in-flight drain, set() enqueues and returns; the enclosing drain absorbs it.
Desugar
Section titled “Desugar”# Desugar: signal NAME = EXPRPara (.pts): signal count = 0;TS (.pts): const count = require("@lyku/para-signals").signal(0);> The canonical ParaBun lowering and the @lyku/para-transpile mirror both emit the require("@lyku/para-signals").signal(EXPR) form verbatim (this is the parity-asserted byte target — transformSignalDecls). A module that prefers an import binding ejects the require per the glass floor; the spec asserts only the referenced helper (signal), not the import style.
The .pui lowering differs because the cell must bridge into the renderer’s $state. The full rules (escape analysis, HMR) are [→ ch:pui-component-model]; the two shapes are previewed in §1.4 because a desugaring that omitted them would mislead.
Examples
Section titled “Examples”signal count = 0;signal name: string = "ada";count.set(count.get() + 1); // explicit get/set in .pts (bare-read is §9.1, Proposed)count.update(n => n + 1);console.log(count.peek()); // non-subscribing read// ✗ counter-examplesignal total: int = "0"; // ✗ host error: string not assignable to int — no silent any (I3)1.4 .pts vs .pui shape (preview; full rules → ch:pui-component-model)
Section titled “1.4 .pts vs .pui shape (preview; full rules → ch:pui-component-model)”In .pts, a signal is a WritableSignal value: you call .get()/.set()/.peek() (or use §9.1 bare-read, Proposed). In .pui, the same keyword bridges to runes so the value reads as a plain variable in markup. Escape analysis (LYK-886) picks one of two shapes:
# Desugar: signal NAME = EXPR (.pui, two shapes — full rules → ch:pui-component-model)Para (.pui): signal count = 0;Runes (.pui, INLINED — count provably component-local): let count = $state(0); // assignments stay native; no para bridgeRunes (.pui, BRIDGED — count escapes via signalOf/context/export): const __sig_count = signal(0); let count = $state(__sig_count.peek()); $effect.pre(() => __sig_count.subscribe((__v: typeof count) => { count = __v; })); // assignments rewrite to __sig_count.set(EXPR); subscription cleanup runs onDestroyThe bridge keeps a WritableSignal alongside the $state cell only when an external Para observer can see it (the conservative fallback — over-inlining would silently drop cross-system updates, a correctness bug). This is the I2 seam: the keyword is identical across surfaces; only the lowering target differs. This chapter treats signal as its .pts WritableSignal meaning; the bridge is the view-substrate’s business.
2. derived — the read-only computed cell
Section titled “2. derived — the read-only computed cell”A derived is a lazily-memoized read-only cell whose value is a function of other reactive reads, re-computed on demand when any dependency changes.
Grammar
Section titled “Grammar”DerivedDecl ::= "derived" Ident TypeAnn? "=" Expr ";" ⟨expression form⟩ | "derived" Ident Block ⟨block form → $derived.by⟩Lexical note. derived fires before NAME [=,;:!{] (catalog derived-decl). The { alternative distinguishes the block form (derived NAME { … }) from the expression form (derived NAME = EXPR). The block form’s body is a statement list ending in an implicit/explicit value; it exists for multi-statement computations that an expression cannot express. As with signal, the expression’s end is scanned ASI-aware (derivedInitEnd) so a ternary/binary/member-chain can wrap across lines.
Static semantics
Section titled “Static semantics”- Read-only binding.
NAMEbinds to aDerivedSignal<T>(or, in.pui, a$derived/$derived.byvalue). There is no.set— assigning a derived is a compile error in surface terms (the lowered value has noset; the type carries no writer). This is the structural difference fromsignal. Tinference. Expression form infersTfromEXPR; block form infers from the block’s result expression.TypeAnnfixesTand the body must produce it.- Purity obligation. A
derivedbody SHOULD be pure (referentially transparent, no side effects, no writes to reactive cells) — it may run zero, one, or many times and its result is memoized. Writing a signal from aderivedbody risks INV-react-2 (cycle) and yields undefined ordering. Thepuremarker and its checking are [→ ch:purity-and-determinism]; this chapter states the obligation, that chapter enforces it.
Dynamic semantics
Section titled “Dynamic semantics”A DerivedSignal carries _value, _dirty (starts true), _subs, and _deps. It is lazy (computes on first read) and push-dirty / pull-recompute:
get()— if_dirty,_recompute(); thentrack(this)(a derived is also a node — effects that read it subscribe to it) and return_value._recompute()— clears its old dep edges (for d of _deps: d._subs.delete(this);_deps.clear()), setscurrentEffect = this(the derived plays the tracking role), runs_compute(), restorescurrentEffect, clears_dirty. Because tracking is re-established every recompute, the dependency set is dynamic (§5): a branch not taken this run drops its subscription._invalidate()— if already_dirty, returns (idempotent — the empty-cascade short-circuit). Otherwise marks_dirtyand propagates_invalidate()to its own_subs(effects/deriveds reading it). Note: invalidation is push (eager dirty-marking) but recomputation is pull (lazy, on next read) — a derived nobody reads is never recomputed.peek()— recomputes if dirty, returns_value, but does nottrack. The non-subscribing read of a derived.
> Edge case (diamond / derived-through-derived). When set() invalidates a signal that feeds both an effect directly and a derived the same effect reads, the set() wraps the whole invalidation cascade in a batch (batchDepth++ around the _subs loop). This guarantees the derived is marked dirty before the effect drains, so the effect recomputes against the fresh derived — never a stale read. This batched-invalidation is why signal.set enters the batch machinery even for a single write with a diamond downstream.
Desugar
Section titled “Desugar”# Desugar: derived NAME = EXPR (expression form)Para (.pts): derived doubled = count.get() * 2;TS (.pts): const doubled = require("@lyku/para-signals").derived(() => count.get() * 2);# Desugar: derived NAME { BODY } (block form)Para (.pui): derived summary { const items = cart.items; return items.length === 0 ? "empty" : `${items.length} items`; }Runes (.pui): const summary = $derived.by(() => { const items = cart.items; return items.length === 0 ? "empty" : `${items.length} items`; });> The expression form wraps the RHS in () => EXPR and routes through derived(...) (.pts) / $derived(...) (.pui); the block form routes through $derived.by(() => { … }) (LYK-892). In .pts the block form lowers to derived(() => { … }) (a statement-bodied arrow). If EXPR reads no signals, the result is a derived that never re-fires — Para does not error (mirroring signal x = LITERAL); the user is explicit. The §9.1 bare-read sugar makes derived doubled = count * 2 read without .get(); §9.4 adds a custom-equality form.
Examples
Section titled “Examples”signal first = "Ada";signal last = "Lovelace";derived full = first.get() + " " + last.get(); // recomputes when either changesconsole.log(full.peek()); // "Ada Lovelace", computed on demand// ✗ counter-examplederived d = count.get();d.set(5); // ✗ error: DerivedSignal has no `set` — a derived is read-only3. The reactive graph & synchronous drain
Section titled “3. The reactive graph & synchronous drain”The three node kinds share one shape so that signal.set can treat every subscriber uniformly. Their relationship and the drain protocol are the heart of INV-react-1.
Node roles
Section titled “Node roles”| Node | Reads track? | Has subs? | Has deps? | Re-runs? |
|---|---|---|---|---|
WritableSignal | source (read tracks the observer) | yes | no | n/a — written, not run |
DerivedSignal | source and observer | yes | yes | recomputes lazily on read after dirty |
Effect | observer only | no (leaf) | yes | re-runs eagerly when a dep changes |
An effect is a leaf: it observes but nothing observes it, so its invalidation must do something immediately rather than wait to be pulled. A derived is a hinge: it observes (so it has _deps) and is observed (so it has _subs), letting it sit between signals and effects and absorb diamonds (§2 edge case).
Tracking
Section titled “Tracking”currentEffect : the active observer (Effect or DerivedSignal), or nulltrack(node) : if currentEffect, add node→currentEffect._deps and currentEffect→node._substrack is the single funnel through which every dependency edge is formed. It is called by WritableSignal.get and DerivedSignal.get, never by peek. This is the entire subscription mechanism — there is no decorator, no compile-time dependency extraction; deps are discovered by running the body and seeing what it reads (§5).
The queue and the eager drain
Section titled “The queue and the eager drain”queue : Set<Effect> (de-dups — an effect invalidated twice runs once per drain)batchDepth : int (suppresses drains while > 0)flushing : bool (prevents re-entrant drains)The protocol (numbered, since ordering is normative):
effect._invalidate()→enqueue(effect):queue.add(effect); ifbatchDepth === 0 && !flushing, calldrain().drain()setsflushing = true, then loops while the queue is non-empty: snapshotpending = Array.from(queue),queue.clear(), run each non-disposed effect’s_execute(). The loop re-checksqueue.sizeso an effect scheduled during the drain (because it ran and a downstreamsetenqueued another effect) is absorbed in the same drain — not deferred to a microtask.flushing = falsein afinally.
> Edge case (re-entrant scheduling). A set() inside an effect body does not start a nested drain: flushing is true, so enqueue just appends. The outer drain()’s while (queue.size) picks it up. This is how the core stays synchronous yet single-pass — there is exactly one live drain at a time, and it runs to fixpoint.
> Edge case (drain order is not topological). The queue is a Set; effects drain in insertion order, not a computed topological order. Correctness does not depend on effect order because deriveds are dirtied eagerly before any effect runs (the batched invalidation, §2 edge case), so whatever order effects fire in, each reads up-to-date derived values. Order among independent effects is therefore insertion order and SHOULD NOT be relied upon for semantics (an effect that needs another effect’s write is a smell — model it as a derived).
Effect re-run protocol
Section titled “Effect re-run protocol”Effect._execute() (also the constructor’s first action — effects run once eagerly on creation):
- If disposed, return.
- If a cleanup from the prior run exists (
typeof _cleanup === "function"), run it (swallowing throws), null it. - Clear old dep edges (
for d of _deps: d._subs.delete(this);_deps.clear()) — the precondition for dynamic re-subscription (§5). - Set
currentEffect = this, runfn(). Iffnreturns a function, store it as_cleanup. RestorecurrentEffect.
Effect.dispose() runs the final cleanup and severs all dep edges; idempotent.
4. effect — the imperative subscriber
Section titled “4. effect — the imperative subscriber”An effect runs a body immediately and re-runs it whenever any reactive value it read changes. It is the only core form that performs side effects.
Grammar
Section titled “Grammar”EffectDecl ::= "effect" Block ⟨statement-bodied; explicit return cleanup⟩ | "effect" Expr ";" ⟨expression-bodied; implicit-return cleanup⟩Lexical note (statement-position disambiguation). effect is the keyword only at statement position followed by { or whitespace + an identifier-start (catalog effect: \b(effect)\b(?=\s*\{|\s+[A-Za-z_$])). The forms effect(, effect., effect[, effect=, effect:, effect; keep effect as a plain identifier — so const effect = …; effect(x) is untouched (superset closure). The single-statement form is scanned with the same ASI-aware derivedInitEnd end-finder as derived.
Static semantics
Section titled “Static semantics”- Block vs expression cleanup channel. Both forms register a teardown the same way — if the body’s value is a function, it is the cleanup — but they differ in how that value arises:
- Block form
effect { … }: cleanup is an explicitreturn someFninside the block. No return ⇒ no cleanup. - Expression form
effect EXPR: the expression’s value is the (possible) cleanup.effect useKeybind("ctrl+s", save)registersuseKeybind’s returned unsubscribe as the cleanup — the implicit-return channel. This is the precise reason the expression form lowers to an expression-bodied arrow() => EXPR(preserving the value) rather than a statement-bodied() => { EXPR }(which would discard it).
- Block form
- No type obligation. An effect produces no binding (it returns a dispose handle in
.pts—effect(...)returns() => e.dispose()— but the surface form is a statement, not a declaration). Its body is type-checked as ordinary host code. - Purity is not required (unlike
derived) — effects are exactly where side effects live (I-principle 5: effects are explicit and tracked).
Dynamic semantics
Section titled “Dynamic semantics”Governed by §3’s Effect._execute. Key consequences:
- Eager first run. The body runs synchronously at the point of declaration, establishing the initial dependency set.
- Cleanup-before-rerun (LIFO within an effect). On each re-run, the prior run’s cleanup fires before the body re-executes (step 2 of
_execute) — so an effect that opens a subscription closes the old one before opening the new. On dispose, the last cleanup fires once. - Re-subscription each run (§5): the dep set is rebuilt every execution; a dependency only read on the not-taken branch is dropped.
Desugar
Section titled “Desugar”# Desugar: effect { BODY } (block form)Para (.pts): effect { console.log("count is", count.get()); return () => console.log("cleanup"); }TS (.pts): require("@lyku/para-signals").effect(() => { console.log("count is", count.get()); return () => console.log("cleanup"); });# Desugar: effect EXPR (expression form — implicit-return cleanup)Para (.pts): effect useKeybind("ctrl+s", save);TS (.pts): require("@lyku/para-signals").effect(() => useKeybind("ctrl+s", save)); // ^ expression-bodied: the return value (an unsubscribe) // becomes the effect's cleanup — NOT discarded.> .pui lowers effect to $effect(() => { … }) (and $effect.pre for the cross-system bridge cases) [→ ch:pui-component-model]. The expression-vs-block distinction and the implicit-return cleanup channel are identical across surfaces.
Examples
Section titled “Examples”signal count = 0;effect { console.log("count =", count.get()); // runs now (count=0), and on every change}count.set(1); // synchronously logs "count = 1" before this returns (INV-react-1)// .pts — implicit-return cleanupsignal el = document.body;effect addResizeListener(window, onResize); // unsubscribe returned by addResizeListener is the cleanup// ✗ counter-example (disambiguation)const effect = make();effect(x); // NOT lowered — `effect(` keeps `effect` as a plain identifier (superset closure)5. Dynamic dependency tracking
Section titled “5. Dynamic dependency tracking”Dependencies are discovered by execution, not declared, and rebuilt on every run. This is the property that makes conditional reactivity correct without a static dependency graph.
Mechanism
Section titled “Mechanism”Each observer (Effect or DerivedSignal) holds _deps: Set. On every _execute/_recompute:
- The observer first tears down its existing edges (
for d of _deps: d._subs.delete(this); _deps.clear()). - Sets
currentEffect = this. - Runs the body. Every
signal.get()/derived.get()reached this run callstrack, re-adding the edge. - Restores
currentEffect.
Because the set is cleared and rebuilt, the dependency set is exactly the cells read on the most recent run — no more, no less.
Why it matters (conditional dependencies)
Section titled “Why it matters (conditional dependencies)”signal showName = true;signal name = "Ada";signal id = 42;effect { if (showName.get()) console.log(name.get()); // depends on showName + name else console.log(id.get()); // depends on showName + id}- Initial run (
showName=true): deps ={showName, name}. A write toiddoes not re-run the effect. showName.set(false): re-runs; deps rebuilt to{showName, id}. Nownamewrites are ignored andidwrites re-run it.
> INV-react-4 (no stale subscriptions). After any observer run, its _subs membership on each former dependency is removed unless that dependency was read again. There is no leak of a no-longer-relevant edge — name cannot wake an effect that no longer reads it.
> Edge case (read a signal you also write in the same effect). An effect that both reads and writes the same signal will, on the write, attempt to re-enqueue itself. The Object.is guard (§6.1) stops this when the value is unchanged; when it genuinely changes, the effect re-runs once more (drained in the same loop) and either converges (next value equal → guard stops) or loops. This is the runtime face of the acyclic obligation (INV-react-2): the language does not forbid it, the discipline (deriveds for computed values, effects for genuine side effects) does. A non-converging self-write is a program bug, surfaced as a hang, not a silent miss.
6. Change detection, guards, and batching
Section titled “6. Change detection, guards, and batching”6.1 Object.is change detection
Section titled “6.1 Object.is change detection”WritableSignal.set(v) short-circuits when Object.is(v, _value). Consequences:
signal x = 0; x.set(0)— no notification, no drain.NaNis handled correctly (Object.is(NaN, NaN) === true);+0/-0are distinguished (Object.is(+0, -0) === false).- Reference, not structural, equality for objects.
x.set({...x.peek()})notifies even if the new object is field-equal, because it is a different reference. To suppress notification on a structurally-equal object, use the Proposed custom-equality derived (§9.4) or replace with a value theObject.isguard accepts.
6.2 Empty-subs guard
Section titled “6.2 Empty-subs guard”If _subs.size === 0, set() stores the value and returns before entering the batch/drain machinery. Rationale and guarantees:
- Why it is safe. The new value is already stored, so later
get()/peek()observe it. And wheneverbatchDepth === 0the queue is already empty (writes drain eagerly), so the skippeddrain()was always a no-op on the unobserved-write path. - Why it exists. It elides the
Array.from(_subs)snapshot + thebatchDepthdance + thedrain()call on the hot unobserved-write path — measured (per the synced-mirror bench) as the bulk of the per-mirror update tax (~5.3% of update cost; ~168 ns/mirror). A signal that nobody is currently observing is a pure store.
> INV-react-5 (empty-subs transparency). The empty-subs guard is semantically invisible: for any observation sequence, a signal with the guard and one without are indistinguishable. It is an optimization, asserted by a test that counts drain() invocations (__test.drainCount) and verifies a subscriber-less set performs zero drains.
6.3 batch(fn) — coalesced transactions
Section titled “6.3 batch(fn) — coalesced transactions”# Desugar: batch — runtime helper (no surface keyword)TS: batch(() => { a.set(1); b.set(2); c.set(3); }); // effects depending on a,b,c run once totalbatch(fn) increments batchDepth, runs fn, decrements, and at depth 0 drain()s. While batchDepth > 0, every set enqueues its subscribers (via the invalidation cascade) but no drain fires; the single drain at the end runs each affected effect once, against the fully-updated values. Batches nest (depth-counted) — only the outermost exit drains.
> Dynamic semantics (batch + diamond). Inside a batch, deriveds are still invalidated eagerly (dirty-marked) on each set, but recomputed lazily when an effect finally reads them in the post-batch drain — so a derived feeding two batched signals recomputes at most once.
6.4 Glitch freedom & the cycle hazard
Section titled “6.4 Glitch freedom & the cycle hazard”- Glitch freedom. Because
signal.setbatches its own invalidation cascade (§2 edge case) and deriveds dirty-mark before effects drain, an effect never observes a partially-updated derived (a “glitch”). Within a singleset(or abatch), all reachable deriveds are dirtied before any reachable effect runs. - Cycles (INV-react-2). A
derived/effectthat writes a signal it transitively reads forms a cycle. The core does not detect it statically; at runtime it either converges via theObject.isguard or diverges (hang). The structural defense is the purity obligation onderived([→ ch:purity-and-determinism]) and the discipline that effects produce side effects, not feedback into their own inputs.
6.5 untrack(fn) — read without subscribing
Section titled “6.5 untrack(fn) — read without subscribing”# Desugar: untrack — runtime helperTS: effect(() => { const live = a.get(); // tracked: re-runs when a changes const once = untrack(() => b.get()); // read b WITHOUT subscribing log(live, once); });untrack(fn) saves currentEffect, sets it to null, runs fn, restores. Any get() inside fn finds no active observer and therefore does not track. Use it to read a value an effect should use but not depend on. The §9.3 Proposed peek/tracked surface makes this a first-class read syntax rather than a wrapping call.
7. signal NAME every MS — the interval-driven signal
Section titled “7. signal NAME every MS — the interval-driven signal”Grammar
Section titled “Grammar”SignalDecl ::= "signal" Ident TypeAnn? "=" Expr Interval? ";"Interval ::= "every" Expr ⟨MS — the interval in milliseconds⟩Lexical note. every is a postfix keyword: it is recognized only when preceded by a )/digit/identifier-char and followed by a number/(/identifier (catalog every-postfix: (?<=[\)\d\w$])\s+(every)\s+(?=\d|[A-Za-z_$\(])). This keeps an identifier beginning with every from being mis-lexed. MS (Expr) may itself be reactive or a unit literal (the §90 100ms proposal, [→ ch:lexical-and-expression-syntax]).
Static & dynamic semantics
Section titled “Static & dynamic semantics”- The cell is an ordinary
WritableSignal<T>whose value is re-evaluated fromEXPRon a timer of periodMS.EXPRis re-run everyMSms (untracked, like the initial RHS) and the resultset()into the cell; theObject.isguard suppresses no-op ticks. - The interval is owned by the declaration’s scope: in
.puiit is auto-cleared on component unmount (the bridge registers anonDestroyclear); in.ptsit follows the enclosing scope’s teardown discipline ([→ ch:effects-lifecycle-and-concurrency] for explicit-scope cases). - The canonical use is a clock or poll:
signal now = Date.now() every 1000ticks once a second.
Desugar
Section titled “Desugar”# Desugar: signal NAME = EXPR every MSPara (.pts): signal now = Date.now() every 1000;TS (.pts): const now = require("@lyku/para-signals").signal(Date.now()); const __int_now = setInterval(() => now.set(Date.now()), 1000); // teardown: clearInterval(__int_now) at scope exit (onDestroy in .pui)> The interval form is not a new node kind — it is a WritableSignal plus a timer that writes it. Everything downstream (deriveds, effects, the empty-subs guard) behaves identically; the only difference from a hand-written setInterval(() => now.set(…)) is that Para owns the teardown (glass floor: a developer reads the setInterval and can eject it).
Example
Section titled “Example”<script lang="ts"> signal now = Date.now() every 1000; derived clock = new Date(now).toLocaleTimeString();</script><p>{clock}</p>8. Binding operators ~> and ->
Section titled “8. Binding operators ~> and ->”Two operators lower a common pattern — “keep a sink in step with a reactive source” — to an effect. They are expression-position operators that emit a statement-position effect.
Grammar
Section titled “Grammar”ReactiveBind ::= Expr "~>" Expr ⟨A ~> B : B is the sink, A the source⟩CallBind ::= Expr "->" Expr ⟨A -> fn : fn(A) on every change⟩Lexical note. ~> (catalog rbind-op) and -> (catalog callbind-op) are multi-char and unambiguous against <. -> carries a negative lookbehind guarding against -->, =>, and <- ((?<![\-=<])->); the scanner additionally rejects a following >/=. Both bind at assign precedence — looser than ||/&&, tighter than the comma operator — and their operand bounds are found by walking balanced parens to the nearest statement delimiter (;, top-level ,, ), ], }, newline). LHS/RHS may span template literals (the scanner is string-aware).
Static & dynamic semantics
Section titled “Static & dynamic semantics”A ~> B(reactive-bind / assignment binding). Lowers to aneffectthat assignsB = A. Any reactive read insideAis tracked (because the assignment runs in an effect body), so the binding re-fires on dependency change and keepsBsynchronized toA.Bis the sink (an lvalue);Ais an arbitrary expression.A -> fn(call-bind). Lowers to aneffectthat callsfn(A).fnmust be a callable target (identifier, property access, or index access) — the canonical parser rejects arrow literals and bare calls as the RHS. (The JS mirror does not re-check this; a non-callablefnthrows at first dependency change.) Use it to push a reactive value into an imperative sink (level -> meter.setValue).- Both inherit all
effectsemantics: eager first run, dynamic re-subscription, synchronous settle.
Desugar
Section titled “Desugar”# Desugar: A ~> B (reactive-bind)Para (.pts): count.get() * 2 ~> doubled;TS (.pts): require("@lyku/para-signals").effect(() => { doubled = count.get() * 2; });# Desugar: A -> fn (call-bind)Para (.pts): level.get() -> meter.setValue;TS (.pts): require("@lyku/para-signals").effect(() => { meter.setValue(level.get()); });Example
Section titled “Example”signal celsius = 20;let fahrenheit = 0;celsius.get() * 9/5 + 32 ~> fahrenheit; // fahrenheit tracks celsius reactivelycelsius.get() -> console.log; // logs on every change> Note the asymmetry that motivates the Proposed two-way bind (§9.2): ~> is one-directional — it drives B from A but a write to B does not flow back to A. A genuine two-way binding requires the paired-effect, loop-suppressed lowering of <~>.
9. Proposed Extensions
Section titled “9. Proposed Extensions”> All constructs in this section are Proposed (net-new surface, not in language-surface.ts/the parser today). Each includes a desugaring sketch so it remains glass-floor-compatible (I1) and lowers onto the existing runtime (signal/derived/effect/batch/untrack/proxySignal) — none requires a new node kind. They are gathered here because each is a reactive-core surface concern.
9.1 Bare-read sugar — count ⇒ count.get() Proposed
Section titled “9.1 Bare-read sugar — count ⇒ count.get() Proposed”Rationale. In .pts, a signal read today is an explicit count.get(); in .pui the bridge already hides it. The asymmetry is a papercut and a legibility tax (A3 — heavy syntax breeds avoidance). Bare-read sugar lets .pts read a signal by name inside tracked contexts, matching .pui and Svelte runes, without making the rule ambient (an un-principled “every identifier is a signal read” would be unreadable and break superset closure). The existing transpile mirror explicitly defers this to “v0.2 — it requires real scope analysis”; this section specifies the triggering rule that makes it sound.
Grammar (sugar). No new tokens. The sugar is a read rewrite keyed on a name’s binding:
BareRead ::= Ident ⟨only when Ident resolves to a signal/derived binding AND appears in a tracked read position⟩The triggering rule (principled, not ambient). A bare Ident rewrites to Ident.get() iff all hold:
Identresolves (lexically) to a binding introduced bysignal/derived/every/source/async signalin scope (a reactive binding — the rewriter has this set from the declarations it lowered).- The occurrence is in a value (read) position — not the LHS of an assignment, not the cell-name in its own declaration, not after
./in a member-access head where it is the object being.get-ed already, and not insidepeek(...)/tracked(...)(§9.3) oruntrack(...). - It is not in a position that wants the cell itself — passing the signal to
signalOf(x),x.subscribe(...),x.set(...), oreffect(... x ...)wherexis used as the handle. The rule: bare name = value; explicit.set/.subscribe/signalOf= handle.
A write uses count = EXPR (Proposed assignment sugar → count.set(EXPR)) or the explicit count.set(EXPR).
Static semantics. The rewrite is purely syntactic and scope-resolved; it introduces no any and changes no types (count had type WritableSignal<number>; count.get() has type number — the sugar yields the value type, which is what every read site wanted). Shadowing is respected: a local const count = … shadowing a signal disables the sugar for that scope (rule 1 fails).
Dynamic semantics / desugar. Identical to writing .get() by hand — so tracking, the empty-subs guard, everything is unchanged.
# Desugar (proposed): bare-read sugarPara (.pts): signal count = 0; derived doubled = count * 2; // count ⇒ count.get() effect { console.log(count); } // count ⇒ count.get() (tracked) count = count + 1; // LHS handle + RHS read ⇒ count.set(count.get() + 1)TS (.pts): const count = signal(0); const doubled = derived(() => count.get() * 2); effect(() => { console.log(count.get()); }); count.set(count.get() + 1);Counter-example.
//#![bare-read] (Proposed: opt-in per file so superset closure is preserved for legacy .pts)signal count = 0;const handle = count; // ✗ error under bare-read: bare `count` is a VALUE; to take the handle write `signalOf(count)` or read via .get explicitlycount.subscribe(log); // ok — explicit handle method, not rewritten> Why opt-in (the superset tension). Making bare-read unconditional would mean a pure-host .pts where a non-signal happens to share a name is unaffected (rule 1 gates it), but a file mixing a signal and a same-named plain read could shift meaning. To keep INV-overview-4 (superset closure) exact, bare-read is gated by a //#![bare-read] pragma (the §-overview pragma machinery); inside the pragma the rule above is total. .pui keeps its existing always-on bridge (the substrate already owns the read).
9.2 Two-way bind A <~> B — paired effects with loop-suppression Proposed
Section titled “9.2 Two-way bind A <~> B — paired effects with loop-suppression Proposed”Rationale. ~> is one-directional (§8); a two-way binding (form field ⇄ model, replica ⇄ local edit) today needs two hand-written, manually loop-guarded effects. <~> makes the bidirectional case one operator, with the loop-suppression that naive paired effects get wrong (each effect’s write would re-trigger the other forever).
Grammar.
TwoWayBind ::= LValue "<~>" LValue ⟨both sides are reactive lvalues⟩Lexical note. <~> is multi-char, unambiguous against ~>/<~/<=. Both operands MUST be assignable reactive cells (signals or bridged $state); binding a derived (read-only) on either side is an error ('<~>' requires writable cells; <name> is derived/read-only).
Static & dynamic semantics. Lowers to two effects, one each direction, sharing a loop-suppression latch: when one direction is mid-write, the other direction’s effect is inhibited so the echo does not bounce back. After both settle, an Object.is guard on each side prevents a no-op write (so a value that round-trips equal terminates). Initialization seeds right-to-left by convention (A takes B’s value on creation — the model is the source of truth), documented so the initial direction is not surprising.
# Desugar (proposed): A <~> BPara (.pts): model.field <~> input.value;TS (.pts): { let __bind_lock = false; effect(() => { // B → A const v = input.value; // tracks input.value if (__bind_lock) return; __bind_lock = true; try { if (!Object.is(model.field, v)) model.field = v; } finally { __bind_lock = false; } }); effect(() => { // A → B const v = model.field; // tracks model.field if (__bind_lock) return; __bind_lock = true; try { if (!Object.is(input.value, v)) input.value = v; } finally { __bind_lock = false; } }); }> The __bind_lock latch is the loop-suppression: a write inside one effect sets the latch, so the other effect — which the write would schedule via the synchronous drain — early-returns instead of writing back. The Object.is guards make even a latch-missed echo a no-op. This is the smallest lowering that is glass-floor-readable; an implementation may instead suppress at the signal level (skip the reverse effect’s enqueue during the forward write) for fewer guard checks.
Example.
<script lang="ts"> signal name = ""; // two-way: typing updates `name`, and a programmatic name.set updates the field name <~> formState.name;</script><input value={name} oninput={e => name = e.target.value} />9.3 First-class peek / tracked read syntax Proposed
Section titled “9.3 First-class peek / tracked read syntax Proposed”Rationale. untrack(() => x.get()) is the only way today to read-without-subscribing inside an effect, and it is a wrapping call that nests awkwardly mid-expression. A postfix read syntax makes “depend on this” vs “use but don’t depend” a local, legible choice at the read site — the dual of bare-read (§9.1).
Grammar.
PeekRead ::= "peek" Ident ⟨non-subscribing read — Ident.peek()⟩TrackedRead ::= "tracked" Ident ⟨explicit subscribing read — Ident.get(); the default under bare-read⟩Lexical note. peek/tracked are contextual prefix keywords recognized only before a reactive Ident (same binding-set gate as §9.1), so const peek = … is untouched. peek x is sugar for x.peek(); tracked x is sugar for x.get() — useful inside an untrack block to re-opt-into tracking for one read.
Static & dynamic semantics. peek x reads the current value with no track (no subscription); tracked x forces a tracked read even where the surrounding context untracks. Both yield the value type. They compose with bare-read: under //#![bare-read], x is tracked x by default and peek x is the explicit opt-out.
# Desugar (proposed): peek / trackedPara (.pts): effect { const live = total; // bare-read ⇒ tracked ⇒ total.get() (re-runs on change) const snap = peek limit; // peek ⇒ limit.peek() (no subscription) check(live, snap); }TS (.pts): effect(() => { const live = total.get(); const snap = limit.peek(); check(live, snap); });Example.
// .pts — read `config` once without making the effect depend on iteffect { const cfg = peek config; // use config but don't re-run when it changes applyTheme(theme, cfg); // re-runs only when `theme` changes}9.4 derived with custom equality — cut spurious notifications Proposed
Section titled “9.4 derived with custom equality — cut spurious notifications Proposed”Rationale. A derived notifies its subscribers whenever its recomputed value fails Object.is against the prior value — but for object/array results, a structurally-equal recompute is a different reference and fires a spurious cascade. A custom-equality form lets a derived declare “notify only when this comparison says different,” cutting downstream effect churn (the most common reactive-perf footgun).
Grammar.
DerivedDecl ::= … | "derived" Ident "by" EqFn "=" Expr ";" | "derived" Ident "by" EqFn BlockEqFn ::= Ident | ArrowExpr ⟨(prev: T, next: T) => boolean — true ⇒ EQUAL ⇒ suppress⟩Lexical note. by is a contextual infix keyword legal only between the derived’s name and its =/{ (it cannot shadow an identifier in value position). EqFn returning true means “equal — suppress notification.”
Static & dynamic semantics. The derived stores its last value and, on recompute, calls EqFn(prev, next); if true, it keeps prev as _value and does not invalidate _subs (the recompute happened — laziness is unchanged — but the propagation is suppressed). EqFn must be pure and total over T; an impure equality is a correctness hazard (it could suppress a real change). The default (no by) is Object.is. The runtime gains one comparator field on DerivedSignal; the change-suppression rides the existing _invalidate short-circuit.
# Desugar (proposed): derived NAME by EQ = EXPRPara (.pts): derived visible by shallowEqual = items.get().filter(i => i.active);TS (.pts): const visible = derivedWith(shallowEqual, () => items.get().filter(i => i.active)); // derivedWith(eq, compute): a DerivedSignal whose _recompute compares // eq(prevValue, nextValue); when true, _value stays and _subs are NOT invalidated.Example.
// .pts — a sorted view that only notifies when the SET of ids changesderived ids by (a, b) => a.length === b.length && a.every((x, i) => x === b[i]) = rows.get().map(r => r.id).sort();effect { render(ids.get()); } // does NOT re-render when rows change but the id list is identical> Interaction. Custom equality is the derived counterpart to §6.1’s reference-equality note: where a signal write can be made a no-op by reusing the value, a derived cannot control its recomputed reference — by EQ is how it regains the suppression. It composes with §9.1 bare-read (ids reads as a value).
9.5 Effect scheduling annotations — pre/post/idle/raf Proposed
Section titled “9.5 Effect scheduling annotations — pre/post/idle/raf Proposed”Rationale. The core drains effects synchronously and eagerly (INV-react-1) — correct for logic, but some effects want a phase: a DOM-measuring effect must run after paint, a layout-writing effect before it, an expensive recompute can wait for idle. .pui already exposes $effect.pre; lifting a small, closed annotation set to the .pts surface makes phase a declared, glass-floor property rather than a manual requestAnimationFrame wrap.
Grammar.
EffectDecl ::= "effect" Phase? Block | "effect" Phase? Expr ";"Phase ::= "pre" | "post" | "idle" | "raf"Lexical note. pre/post/idle/raf are recognized only immediately after effect and before the body ({ or expression). Absent a Phase, the effect is synchronous (today’s default, INV-react-1) — the annotations are strictly additive.
Static & dynamic semantics.
pre— runs before the renderer commits (maps to$effect.prein.pui; in.ptsit is a synchronous pre-flush, same as default but ordered beforepost/rafeffects in the same drain).post— runs after the synchronous drain settles, in aqueueMicrotask(or the renderer’s after-commit phase in.pui).idle— schedules viarequestIdleCallback(falling back to a timeout); for non-urgent work.raf— schedules viarequestAnimationFrame; for visual/measurement work aligned to paint.
Crucially, dependency tracking is unchanged — the body still runs in a tracking context and re-subscribes per run (§5); only when the (re-)run is scheduled changes. A post/idle/raf effect coalesces multiple invalidations within its window into one run (natural debouncing).
# Desugar (proposed): effect PHASE { BODY }Para (.pts): effect raf { positionTooltip(anchor.get()); }TS (.pts): effectScheduled("raf", () => { positionTooltip(anchor.get()); }); // effectScheduled(phase, fn): builds an Effect whose _invalidate enqueues onto the // phase's scheduler instead of the synchronous queue; tracking/_deps unchanged.Example.
<script lang="ts"> signal items = []; effect pre { reserveScrollAnchor(); } // before commit ($effect.pre) effect post { measureAndCache(listEl); } // after commit (DOM is laid out) effect idle { prefetchNext(items); } // when idle</script>> Interaction. Scheduling annotations are the bridge to [→ ch:pui-component-model]‘s $effect.pre and to [→ ch:effects-lifecycle-and-concurrency] (which owns the teardown of scheduled callbacks). They do not weaken INV-react-1 for unannotated effects — the synchronous-settle guarantee is the default and the annotations are an explicit opt-out, per phase, readable in the lowering.
9.6 Reactive collections — signalled Map/Set/array on proxySignal Proposed
Section titled “9.6 Reactive collections — signalled Map/Set/array on proxySignal Proposed”Rationale. A signal holding an array notifies only on reference replacement (arr.set([...])), so arr.peek().push(x) is silently non-reactive — a sharp footgun. The runtime already ships proxySignal (deep-reactive object/array via a tracking Proxy, mirroring Svelte 5 $state); this proposal lifts it to surface for Map/Set/array, so element-level reads and structural mutations are reactive without a manual .set([...spread]).
Grammar.
SignalDecl ::= … | "signal" Ident TypeAnn? "=" CollectionLit ";"CollectionLit ::= ArrayLit | "new" "Map" Args? | "new" "Set" Args? | ObjectLitThe same signal keyword; the RHS being a collection selects the proxySignal lowering (a collection literal is the trigger — no new keyword).
Static & dynamic semantics. Lowers to proxySignal(EXPR) rather than signal(EXPR). Then:
- Reads track per-element/per-key. Reading
state.items[0]subscribes only to that index; readingstate.countsubscribes to that key — leaf writes notify only that key’s signal. - Structural changes bump a
versionsignal.push/delete/new-key/lengthchanges notify effects that iterated (for…of,Object.keys,in) — those reads subscribe toversion. - Arrays keep a
lengthsignal in sync soarr.lengthreads are reactive acrosspush/pop/splice. - Non-plain values pass through unwrapped (
Date/Map-instance/Set-instance/class instances stay as-is — their methods rely onthis-binding that a Proxy breaks). For Map/Set the surface form wraps the container reactively (size + membership via version), but element identity inside is the host’s.
# Desugar (proposed): signal NAME = <collection>Para (.pts): signal state = { count: 0, items: ["a"] };TS (.pts): const state = proxySignal({ count: 0, items: ["a"] }); // state.count, state.items[0], state.items.push("b") are all reactive — no .set spread.Example.
signal cart = { items: [] as Item[], coupon: null };effect { console.log("item count:", cart.items.length); } // tracks lengthcart.items.push(newItem); // length signal fires → effect re-runscart.coupon = "SAVE10"; // key write → only coupon-readers re-run// ✗ counter-example (the footgun this fixes)signal arr = [1, 2, 3]; // (today, with plain signal())arr.peek().push(4); // ✗ silently non-reactive — no notification; proxySignal form fixes this> Interaction. Reactive collections are a .pts/.pui surface over the existing proxySignal runtime — no new node kind, just a different constructor selected by RHS shape. They compose with §9.1 (a proxied object read is a bare value), §9.4 (a derived by shallowEqual over a reactive collection cuts churn), and the sources chapter (proxySignal is also listed there as a source adapter; this section owns its in-process signal surface, the sources chapter owns its use as an external-producer sink) [→ ch:sources-async-and-native].
10. Chapter invariants (summary)
Section titled “10. Chapter invariants (summary)”- INV-react-1 (synchronous settle). A write outside a batch settles the whole reachable graph before returning. §0, §3.
- INV-react-2 (acyclic graph). The graph is a DAG; cycles are a runtime hazard the purity discipline averts. §0, §6.4.
- INV-react-3 (write is settle). A non-empty-subs
setat depth 0 returns only after its drain. §1. - INV-react-4 (no stale subscriptions). Dynamic re-subscription drops edges not re-read. §5.
- INV-react-5 (empty-subs transparency). The empty-subs guard is observationally invisible. §6.2.
Every form in §§1–8 lowers to readable @lyku/para-signals calls or runes (I1); the keyword is surface-uniform and only the lowering target differs across .pts/.pui (I2); no form introduces a silent any (I3).