🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@tmonier/effract

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@tmonier/effract - npm Package Compare versions

Comparing version
0.4.0
to
0.5.0
+428
dist/reactivity-core-Ceq7GLk8.mjs
import * as Equal from "effect/Equal";
import { AtomRef } from "effect/unstable/reactivity";
//#region src/domain/protocol.ts
/** Brand identifying a lifted React hook instruction. */
const HookTypeId = Symbol.for("@tmonier/effract/Hook");
/**
* Lift an already-evaluated React hook result into the effract yield channel.
*
* ```ts
* const [tab, setTab] = yield* hook(useState('overview'));
* const ref = yield* hook(useRef<HTMLDivElement>(null));
* ```
*
* Because the component body runs synchronously inside React's render pass, the
* wrapped hook call obeys the Rules of Hooks: same order on every render.
*/
const hook = (value) => {
const self = {
[HookTypeId]: true,
value,
[Symbol.iterator]() {
let yielded = false;
return { next(sent) {
if (yielded) return {
done: true,
value: sent
};
yielded = true;
return {
done: false,
value: self
};
} };
}
};
return self;
};
/** Type guard: is this yielded instruction a lifted hook? */
const isHook = (u) => typeof u === "object" && u !== null && HookTypeId in u;
/** Brand identifying a suspensable instruction (a `suspend`/`query`). */
const SuspensableTypeId = Symbol.for("@tmonier/effract/Suspensable");
const makeSuspensable = (effect, key) => {
const self = {
[SuspensableTypeId]: true,
effect,
key,
[Symbol.iterator]() {
let yielded = false;
return { next(sent) {
if (yielded) return {
done: true,
value: sent
};
yielded = true;
return {
done: false,
value: self
};
} };
}
};
return self;
};
/**
* The suspensable primitive: run `effect`, suspend the render until it settles
* (through React's `use`), and return its value — declaring a loading obligation
* the type system makes you handle. Load-once: it runs a single time per
* component instance and position, deduped across every render attempt (including
* the pre-commit retries React makes when a component suspends before it first
* commits), and its in-flight fiber is interrupted when the component unmounts.
*
* It is the building block {@link query} is made of — reach for `suspend` to
* opt an effect into Suspense without query's keyed-refetch semantics, or to
* build your own async abstractions on top.
*
* ```ts
* const config = yield* suspend(loadConfig()); // load-once
* const feed = yield* suspend(load.pipe(Effect.timeout('2s'))); // policies via Effect
* ```
*/
const suspend = (effect) => makeSuspensable(effect, void 0);
/**
* A keyed suspensable — {@link suspend} plus refetch. It re-runs `effect` when
* `key` changes by value (leaving `key` off makes it load-once, exactly like
* `suspend`). Same guarantees otherwise: deduped across render attempts,
* interrupted on unmount, failures catchable with `.catch`.
*
* ```ts
* const user = yield* query(fetchUser(id), id); // refetch when id changes
* ```
*/
const query = (effect, key) => makeSuspensable(effect, key);
/** Type guard: is this yielded instruction a suspensable (`suspend`/`query`)? */
const isSuspensable = (u) => typeof u === "object" && u !== null && SuspensableTypeId in u;
/** Brand identifying a reactive atom instruction. */
const AtomTypeId = Symbol.for("@tmonier/effract/Atom");
/** Type guard: is this yielded instruction a reactive atom read? */
const isAtom = (u) => typeof u === "object" && u !== null && AtomTypeId in u;
/** Brand identifying a child-REC placement instruction. */
const PlacementTypeId = Symbol.for("@tmonier/effract/Placement");
/**
* Construct a child placement. Single-shot iterable like {@link hook}: `yield*`
* hands the interpreter the placement instruction, and the value fed back in
* (the rendered child node) becomes the result of the `yield*`.
*/
const placement = (rec, props) => {
const self = {
[PlacementTypeId]: true,
rec,
props,
[Symbol.iterator]() {
let yielded = false;
return { next(sent) {
if (yielded) return {
done: true,
value: sent
};
yielded = true;
return {
done: false,
value: self
};
} };
}
};
return self;
};
/** Type guard: is this yielded instruction a child-REC placement? */
const isPlacement = (u) => typeof u === "object" && u !== null && PlacementTypeId in u;
//#endregion
//#region src/infrastructure/rec-core.tsx
/** Brand identifying a React Effect Component. */
const RecTypeId = Symbol.for("@tmonier/effract/Rec");
const makeRec = (body, name, catchHandlers, suspenseFallback) => {
const rec = {
[RecTypeId]: true,
body,
displayName: name,
...catchHandlers === void 0 ? {} : { catchHandlers },
...suspenseFallback === void 0 ? {} : { suspenseFallback },
with(props) {
return placement(rec, props);
},
catch(handlers) {
return makeRec(body, name, handlers, suspenseFallback);
},
suspense(fallback) {
return makeRec(body, name, catchHandlers, fallback);
},
[Symbol.iterator]() {
return placement(rec, {})[Symbol.iterator]();
}
};
Object.defineProperty(rec, "name", { value: name });
return rec;
};
function rec(body) {
return makeRec(body, body.name || "EffractComponent");
}
//#endregion
//#region src/infrastructure/reactivity-core.ts
/** The single-shot iterator that makes an atom `yield*`able: read + subscribe. */
const yieldSelf = (self) => {
let yielded = false;
return { next(sent) {
if (yielded) return {
done: true,
value: sent
};
yielded = true;
return {
done: false,
value: self
};
} };
};
/**
* Batching: coalesce a burst of writes into a single notification wave. While a
* `batch` runs, every atom/derived listener that would fire is collected instead
* of called; when the outermost batch ends, each is called exactly once. So a
* derived value that reads two atoms both changed in one batch recomputes once,
* and a component reading them re-renders once, not twice. Batches nest — only
* the outermost flush notifies.
*/
let batchDepth = 0;
const pending = /* @__PURE__ */ new Set();
const flush = () => {
const wave = Array.from(pending);
pending.clear();
for (const listener of wave) listener();
};
/** Notify a listener set, deferring into the current batch if one is open. */
const notify = (listeners) => {
if (batchDepth > 0) for (const listener of listeners) pending.add(listener);
else for (const listener of Array.from(listeners)) listener();
};
/**
* Run `writes` as one atomic notification wave: atoms may be `set` many times
* inside, but subscribers (and the derived atoms and components downstream) are
* notified once, after it returns. Returns whatever `writes` returns.
*
* ```ts
* batch(() => {
* first.set('Ada');
* last.set('Lovelace');
* }); // one re-render, not two
* ```
*/
const batch = (writes) => {
batchDepth += 1;
try {
return writes();
} finally {
batchDepth -= 1;
if (batchDepth === 0) flush();
}
};
/**
* Create a writable reactive atom. Read it with `yield*` in a REC, `.value`
* imperatively (in an event handler or a service method), or `$(atom)` inside
* `derive`/`<Observe>`; write it with `.set` / `.update`. Backed by Effect's
* `AtomRef` for storage, so its value is reachable from anywhere an Effect runs;
* writes that don't change the value (by `Equal`) notify no one, and writes made
* inside {@link batch} are coalesced.
*/
const atom = (initial) => {
const ref = AtomRef.make(initial);
const listeners = /* @__PURE__ */ new Set();
const write = (next) => {
if (Equal.equals(next, ref.value)) return;
ref.set(next);
notify(listeners);
};
const self = {
[AtomTypeId]: true,
get value() {
return ref.value;
},
set: (value) => write(value),
update: (update) => write(update(ref.value)),
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
derive: (f) => deriveReadonly((read) => f(read(self))),
[Symbol.iterator]: () => yieldSelf(self)
};
return self;
};
const atomFamily = (make, keyOf = (key) => key) => {
const cache = /* @__PURE__ */ new Map();
const family = ((key) => {
const id = keyOf(key);
if (!cache.has(id)) cache.set(id, make(key));
return cache.get(id);
});
family.forget = (key) => {
cache.delete(keyOf(key));
};
family.clear = () => {
cache.clear();
};
return family;
};
const computation = (selector) => {
const listeners = /* @__PURE__ */ new Set();
let currentDeps = [];
let unsubscribes = [];
let cached;
let hasCached = false;
let tracking = false;
const evaluate = () => {
const deps = [];
const read = (atomRead) => {
deps.push(atomRead);
return atomRead.value;
};
return {
value: selector(read),
deps
};
};
const sameDeps = (deps) => deps.length === currentDeps.length && deps.every((dep, i) => dep === currentDeps[i]);
const listen = (deps) => {
for (const unsubscribe of unsubscribes) unsubscribe();
unsubscribes = deps.map((dep) => dep.subscribe(onChange));
currentDeps = deps;
};
function onChange() {
const { value, deps } = evaluate();
if (!sameDeps(deps)) listen(deps);
const changed = !Equal.equals(value, cached);
cached = value;
hasCached = true;
if (changed) for (const listener of Array.from(listeners)) listener();
}
return {
get: () => {
if (tracking) return cached;
const next = evaluate().value;
if (hasCached && Equal.equals(next, cached)) return cached;
cached = next;
hasCached = true;
return cached;
},
subscribe: (listener) => {
if (listeners.size === 0) {
const first = evaluate();
cached = first.value;
hasCached = true;
tracking = true;
listen(first.deps);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
for (const unsubscribe of unsubscribes) unsubscribe();
unsubscribes = [];
currentDeps = [];
tracking = false;
}
};
}
};
};
const deriveReadonly = (selector) => {
const comp = computation(selector);
const self = {
[AtomTypeId]: true,
get value() {
return comp.get();
},
subscribe: (listener) => comp.subscribe(listener),
derive: (f) => deriveReadonly((read) => f(read(self))),
[Symbol.iterator]: () => yieldSelf(self)
};
return self;
};
/**
* A *writable* derived atom: read like a `derive` (tracked, recomputes when a
* source changes), but also `set`/`update`, with the write routed back through
* `write` to the source atoms. For adapters and two-way projections — a form
* field over a model, a unit conversion — where the value both derives from and
* feeds the atoms beneath it.
*
* ```ts
* const fahrenheit = derive.writable(
* ($) => $(celsius) * 1.8 + 32,
* (f) => celsius.set((f - 32) / 1.8),
* );
* ```
*/
const deriveWritable = (selector, write) => {
const comp = computation(selector);
const self = {
[AtomTypeId]: true,
get value() {
return comp.get();
},
set: (value) => write(value),
update: (update) => write(update(comp.get())),
subscribe: (listener) => comp.subscribe(listener),
derive: (f) => deriveReadonly((read) => f(read(self))),
[Symbol.iterator]: () => yieldSelf(self)
};
return self;
};
const deriveEffect = (selector) => {
const key = deriveReadonly((read) => {
const values = [];
selector((atomRead) => {
const value = read(atomRead);
values.push(value);
return value;
});
return values;
});
const plainRead = (atomRead) => atomRead.value;
return { [Symbol.iterator]() {
let step = 0;
return { next(sent) {
if (step === 0) {
step = 1;
return {
done: false,
value: key
};
}
if (step === 1) {
step = 2;
return {
done: false,
value: query(selector(plainRead), sent)
};
}
return {
done: true,
value: sent
};
} };
} };
};
/**
* Create a *derived* atom from *several* atoms: a read-only reactive value whose
* `$` tracks exactly the atoms the selector reads, so it recomputes — and its
* readers re-render — precisely when a tracked atom changes. Derived atoms
* compose. Keep derivation here, in the Effect world, not in a component.
*
* For the common case of deriving from a *single* atom, prefer the method form
* {@link ReadableAtom.derive | `atom.derive`} — it hands you the value directly,
* with no `$`: `const count = items.derive((list) => list.length)`.
*
* ```ts
* const total = derive(($) => $(price) * $(qty)); // several sources → the $ form
* ```
*
* {@link deriveWritable | `derive.writable`} adds a two-way variant;
* {@link deriveEffect | `derive.effect`} an async, suspending one.
*/
const derive = Object.assign(deriveReadonly, {
writable: deriveWritable,
effect: deriveEffect
});
//#endregion
export { placement as _, derive as a, AtomTypeId as c, SuspensableTypeId as d, hook as f, isSuspensable as g, isPlacement as h, computation as i, HookTypeId as l, isHook as m, atomFamily as n, RecTypeId as o, isAtom as p, batch as r, rec as s, atom as t, PlacementTypeId as u, query as v, suspend as y };
import { ReactNode } from "react";
import * as Effect from "effect/Effect";
//#region src/domain/protocol.d.ts
/**
* The one unavoidable `any` in the framework. The heterogeneous yield protocol
* must accept an Effect of *any* error and requirement variance — there is no
* other way to express "some Effect" as a generic constraint, which is why
* Effect's own `Effect.gen` is typed exactly this way. Precise `E` and `R` are
* recovered below through conditional inference, so this never leaks to users.
*/
type AnyEffect = Effect.Effect<any, any, any>;
/** Brand identifying a lifted React hook instruction. */
declare const HookTypeId: unique symbol;
type HookTypeId = typeof HookTypeId;
/**
* A React hook result lifted into the `yield*` channel. The hook itself has
* already executed (synchronously, during render) by the time the value is
* wrapped — `hook(useState(0))` calls `useState` inline. Wrapping it only makes
* the result yieldable, so a component body reads as one uniform stream of
* `yield*`s whether the value comes from Effect or from React.
*/
interface Hook<out A> {
readonly [HookTypeId]: true;
readonly value: A;
[Symbol.iterator](): Iterator<Hook<A>, A>;
}
/**
* Lift an already-evaluated React hook result into the effract yield channel.
*
* ```ts
* const [tab, setTab] = yield* hook(useState('overview'));
* const ref = yield* hook(useRef<HTMLDivElement>(null));
* ```
*
* Because the component body runs synchronously inside React's render pass, the
* wrapped hook call obeys the Rules of Hooks: same order on every render.
*/
declare const hook: <A>(value: A) => Hook<A>;
/** Type guard: is this yielded instruction a lifted hook? */
declare const isHook: (u: unknown) => u is Hook<unknown>;
/** Brand identifying a suspensable instruction (a `suspend`/`query`). */
declare const SuspensableTypeId: unique symbol;
type SuspensableTypeId = typeof SuspensableTypeId;
/**
* The fourth kind of yieldable: a *suspensable* — a component's declared
* asynchronous dependency. Unlike a raw `yield* effect` (which the interpreter
* may still suspend on, but silently), yielding a suspensable both suspends for
* the value *and* contributes a {@link Suspends} obligation to the REC's type,
* so the loading state must be handled somewhere (`.suspense(...)`, or the
* `mount` boundary) before the tree compiles. Its `effect` carries the `E` and
* `R` channels, which bubble as a yielded effect's do — so retries, timeouts and
* cancellation are just Effect combinators on `effect`, and its failures are
* catchable with `.catch`. `key` (compared by value) drives refetch; `undefined`
* means load-once. Both {@link suspend} and {@link query} produce this.
*/
interface Suspensable<out A, out E, out R> {
readonly [SuspensableTypeId]: true;
readonly effect: Effect.Effect<A, E, R>;
readonly key: unknown;
[Symbol.iterator](): Iterator<Suspensable<A, E, R>, A>;
}
/**
* The suspensable primitive: run `effect`, suspend the render until it settles
* (through React's `use`), and return its value — declaring a loading obligation
* the type system makes you handle. Load-once: it runs a single time per
* component instance and position, deduped across every render attempt (including
* the pre-commit retries React makes when a component suspends before it first
* commits), and its in-flight fiber is interrupted when the component unmounts.
*
* It is the building block {@link query} is made of — reach for `suspend` to
* opt an effect into Suspense without query's keyed-refetch semantics, or to
* build your own async abstractions on top.
*
* ```ts
* const config = yield* suspend(loadConfig()); // load-once
* const feed = yield* suspend(load.pipe(Effect.timeout('2s'))); // policies via Effect
* ```
*/
declare const suspend: <A, E, R>(effect: Effect.Effect<A, E, R>) => Suspensable<A, E, R>;
/**
* A keyed suspensable — {@link suspend} plus refetch. It re-runs `effect` when
* `key` changes by value (leaving `key` off makes it load-once, exactly like
* `suspend`). Same guarantees otherwise: deduped across render attempts,
* interrupted on unmount, failures catchable with `.catch`.
*
* ```ts
* const user = yield* query(fetchUser(id), id); // refetch when id changes
* ```
*/
declare const query: <A, E, R>(effect: Effect.Effect<A, E, R>, key?: unknown) => Suspensable<A, E, R>;
/** Type guard: is this yielded instruction a suspensable (`suspend`/`query`)? */
declare const isSuspensable: (u: unknown) => u is Suspensable<unknown, unknown, unknown>;
/** Brand identifying a reactive atom instruction. */
declare const AtomTypeId: unique symbol;
type AtomTypeId = typeof AtomTypeId;
/**
* The fifth kind of yieldable: a *reactive atom read*. Reactive state lives in
* the Effect world — an atom, usually held by a service. Yielding one reads its
* current value *and* subscribes the component, so it re-renders precisely when
* that atom changes: `const n = yield* count`. It carries no `E`/`R`/`S` — a
* read is synchronous, needs nothing, and cannot fail. Derived atoms
* (`derive(...)`) are `ReadableAtom`s too, so they read and yield identically.
* The concrete atom is built in the React binding; the domain only names the
* contract — a value you can read, subscribe to, and `yield*`.
*/
interface ReadableAtom<out A> {
readonly [AtomTypeId]: true;
/** The current value — read imperatively (in an event handler or a method). */
readonly value: A;
/** Subscribe to changes; returns an unsubscribe. Backs the in-render read. */
subscribe(listener: () => void): () => void;
/**
* Derive a new read-only atom from *this one's* value — the ergonomic,
* single-source form of `derive`. The callback receives the value directly (no
* `$`), and the result recomputes when this atom changes; chain it freely.
* For a value computed from *several* atoms, reach for the free `derive(($) => …)`.
*
* ```ts
* const count = items.derive((list) => list.length);
* ```
*/
derive<B>(f: (value: A) => B): ReadableAtom<B>;
[Symbol.iterator](): Iterator<ReadableAtom<A>, A>;
}
/** A writable reactive atom — a {@link ReadableAtom} you can also `set`/`update`. */
interface Atom<in out A> extends ReadableAtom<A> {
set(value: A): void;
update(update: (previous: A) => A): void;
}
/** Type guard: is this yielded instruction a reactive atom read? */
declare const isAtom: (u: unknown) => u is ReadableAtom<unknown>;
/**
* The phantom marker of an *unhandled loading obligation*. A body that yields a
* {@link Suspensable} (or places a child that still carries one) has this in its
* `S` channel; `.suspense(fallback)` — or the `mount` boundary — discharges it
* back to `never`. It never exists at runtime; it exists only so the type system
* can insist a loading state is handled somewhere between component and root. The
* branded field is nominal — it makes `Suspends` distinct from `never` and from
* any ordinary type, so the discharge checks are exact. Loading is a single
* catch-all obligation (not a union like the tagged error channel): a pending
* effect has no tag, so one `.suspense` discharges the whole subtree beneath it.
*/
interface Suspends {
readonly ['@tmonier/effract/loading']: true;
}
/** Everything a body may `yield*`: an Effect, a hook, a placement, a query, or an atom read. */
type Yieldable<A> = Effect.Effect<A, unknown, unknown> | Hook<A> | RecPlacement<A, unknown, unknown> | Suspensable<A, unknown, unknown> | ReadableAtom<A>;
/** The generator a React Effect Component body produces. */
type RecGenerator<A> = Generator<AnyEffect | Hook<unknown> | RecPlacement<unknown, unknown, unknown> | Suspensable<unknown, unknown, unknown> | ReadableAtom<unknown>, A, unknown>;
/** A component body: props in, a generator of yields ending in a rendered `A`. */
type RecBody<Props, A> = (props: Props) => RecGenerator<A>;
/**
* Runtime dispatch table for `.catch`: an error `_tag` maps to the node to
* render in that error's place. Renderer-agnostic in `A` — the domain never
* learns that a node is a React element; it only carries the mapping so both the
* client interpreter and the server driver can honour it. The typed, exhaustive
* shape users write (`CatchHandlers`) narrows to this at the `.catch` boundary.
*/
type CatchDispatch<A> = Readonly<Record<string, (error: unknown) => A>>;
/**
* A stable handle to a component's body — the identity a placement points at.
* The client keys its per-descriptor React component on this object (so a child
* keeps a stable React type across re-renders); the server drives its `body`
* directly. Deliberately renderer-agnostic: it names *what* to run, not how.
*/
interface RecHandle<A> {
readonly body: (props: any) => RecGenerator<A>;
readonly displayName: string;
/**
* Typed-error fallbacks for this REC, if it was `.catch`-wrapped: a failure
* from one of *its own* `yield*`ed effects whose `_tag` is present renders the
* mapped node instead of propagating. Absent for a plain REC.
*/
readonly catchHandlers?: CatchDispatch<A>;
/**
* The loading fallback for this REC, if it was `.suspense`-wrapped: the client
* places it in a real `<Suspense>` boundary so a yielded {@link Suspensable}'s
* pending state renders this node. Renderer-agnostic in `A`; absent otherwise.
*/
readonly suspenseFallback?: A;
}
/** Brand identifying a child-REC placement instruction. */
declare const PlacementTypeId: unique symbol;
type PlacementTypeId = typeof PlacementTypeId;
/**
* The third kind of yieldable, alongside Effects and Hooks: the placement of a
* *child* REC into a parent's tree (`yield* Child` / `yield* Child.with(p)`).
*
* A placement carries only data — the child's stable `rec` handle and its
* `props` — never a bound React element. That is what lets one `rec(...)` value
* be shared across runtimes: the client turns a placement into a real React
* child fiber, the server drives the child's body inline. The phantom `R` makes
* the child's Effect requirements flow up through `yield*` to `mount`/`serve`,
* exactly as a yielded service's requirements do.
*/
interface RecPlacement<A, R, S = never> {
readonly [PlacementTypeId]: true;
readonly rec: RecHandle<A>;
readonly props: object;
/**
* Phantom carrier for the requirements this placement contributes upward.
* Covariant (an output position) so a concrete placement — e.g. one needing
* `Stats` — widens to `RecPlacement<_, unknown>` in a body's yield union, just
* as `Effect<_, _, Stats>` widens to `AnyEffect`. Never set at runtime.
*/
readonly _requirements?: R;
/**
* Phantom carrier for the child's unhandled loading obligation `S`. Covariant,
* like `_requirements`, so a placed child that still suspends bubbles its
* {@link Suspends} up to the parent's `S` — until an ancestor `.suspense`s it.
* Never set at runtime.
*/
readonly _suspends?: S;
[Symbol.iterator](): Iterator<RecPlacement<A, R, S>, A>;
}
/**
* Construct a child placement. Single-shot iterable like {@link hook}: `yield*`
* hands the interpreter the placement instruction, and the value fed back in
* (the rendered child node) becomes the result of the `yield*`.
*/
declare const placement: <A, R, S = never>(rec: RecHandle<A>, props: object) => RecPlacement<A, R, S>;
/** Type guard: is this yielded instruction a child-REC placement? */
declare const isPlacement: (u: unknown) => u is RecPlacement<unknown, unknown, unknown>;
/**
* Distribute over the yield union and keep only its Effect members. Hooks are
* not Effects, so they drop away — leaving just what carries `E` and `R`.
*/
type EffectsOnly<Eff> = Eff extends AnyEffect ? Eff : never;
/**
* Re-express each yielded placement as an effect carrying only its child's
* requirements, so a placed child's `R` joins the parent's the same way a
* yielded service's does — one child, its whole subtree's services bubble up.
*/
type PlacementsAsEffects<Eff> = Eff extends RecPlacement<unknown, infer R, unknown> ? Effect.Effect<unknown, unknown, R> : never;
/**
* Re-express each yielded suspensable as an effect carrying its `E` and `R`, so
* a suspensable's errors and requirements join the body's exactly as a raw
* effect's do.
*/
type SuspensablesAsEffects<Eff> = Eff extends Suspensable<unknown, infer E, infer R> ? Effect.Effect<unknown, E, R> : never;
/**
* Recover the Effect requirement channel `R` from everything a body yields —
* services, effects, placed child RECs, and suspensables. A body that needs both
* `A` and `B` requires `A & B`, which is exactly the intersection TypeScript
* infers from the contravariant requirement slot.
*/
type RequirementsOf<Eff> = [EffectsOnly<Eff> | PlacementsAsEffects<Eff> | SuspensablesAsEffects<Eff>] extends [Effect.Effect<unknown, unknown, infer R>] ? R : never;
/** Recover the Effect error channel `E` (a union — any yielded effect or suspensable may fail). */
type ErrorsOf<Eff> = [EffectsOnly<Eff> | SuspensablesAsEffects<Eff>] extends [Effect.Effect<unknown, infer E, unknown>] ? E : never;
/**
* Recover the loading obligation `S`. A yielded {@link Suspensable} contributes
* {@link Suspends}; a placed child contributes whatever `S` it still carries.
* The union is `Suspends` if *anything* below is unhandled, and `never` once it
* all is — the exact condition `mount` (and `.suspense`) check.
*/
type SuspendsOf<Eff> = Eff extends Suspensable<unknown, unknown, unknown> ? Suspends : Eff extends RecPlacement<unknown, unknown, infer S> ? S : never;
//#endregion
//#region src/infrastructure/rec-core.d.ts
/** Brand identifying a React Effect Component. */
declare const RecTypeId: unique symbol;
type RecTypeId = typeof RecTypeId;
/** A tagged error — the shape `.catch` dispatches on (`Data.TaggedError`, …). */
type Tagged = {
readonly _tag: string;
};
/** The non-tagged remainder of an error channel — errors `.catch` cannot name. */
type UntaggedErrors<E> = Exclude<E, Tagged>;
/**
* The exhaustive handler map for a REC's error channel: one fallback per error
* `_tag`, each receiving exactly that error. Omitting a tag the body can fail
* with is a compile error (a missing property); an unknown tag is rejected as an
* excess property. A REC that cannot fail with a tagged error takes `{}`.
*/
type CatchHandlers<E, A = ReactNode> = { readonly [Tag in Extract<E, Tagged>['_tag']]: (error: Extract<E, {
readonly _tag: Tag;
}>) => A };
/** Any yieldable a REC body may produce — the constraint `rec` infers `Eff` against. */
type AnyYield = AnyEffect | Hook<unknown> | RecPlacement<ReactNode, unknown, unknown> | Suspensable<unknown, unknown, unknown> | ReadableAtom<unknown>;
interface RecCore<P, E, R, S> extends RecHandle<ReactNode> {
readonly [RecTypeId]: true;
/** Place this REC with props: `yield* Child.with({ ... })`. */
with(props: P): RecPlacement<ReactNode, R, S>;
/**
* Render a typed fallback for each error this REC's body can fail with. The
* handler map is exhaustive over the error channel `E` and checked at compile
* time, so you cannot forget a tag or invent one. Each failure — synchronous
* or async — renders its mapped node in place of the component; defects and
* any non-tagged errors are left to the nearest React error boundary.
*
* ```tsx
* const Profile = rec(function* () {
* const user = yield* query(fetchUser(id), id); // E = NotFound | Unauthorized
* return <Card user={user} />;
* }).catch({
* NotFound: () => <Empty />,
* Unauthorized: () => <Login />,
* });
* ```
*
* Returns a REC whose error channel keeps only the non-tagged remainder
* (usually `never`); the loading obligation `S` is untouched. It catches *this*
* REC's own `yield*`ed failures; a child REC handles its own (wrap it too).
*/
catch(handlers: CatchHandlers<E>): REC<P, UntaggedErrors<E>, R, S>;
/**
* Handle this REC's loading state. A REC that `yield*`s a `suspend`/`query`
* carries a loading obligation `S`; `.suspense(fallback)` discharges it by
* placing the REC in a real `<Suspense>` boundary, so while it is pending the
* `fallback` renders in its place.
*
* ```tsx
* const Page = Profile.suspense(<Skeleton />); // Page: REC<…, never> — obligation met
* ```
*
* Returns a REC with `S` back to `never`. One `.suspense` discharges the whole
* subtree beneath it (React Suspense catches every descendant that suspends),
* so a single boundary — at any ancestor, or at `mount` via `{ loading }` —
* satisfies the obligation the type system otherwise bubbles all the way up.
*/
suspense(fallback: ReactNode): REC<P, E, R, never>;
}
interface RecBareYield<R, S> {
/** Place this REC without props: `yield* Child`. */
[Symbol.iterator](): Iterator<RecPlacement<ReactNode, R, S>, ReactNode>;
}
/**
* A React Effect Component. Yieldable (so `R` and `S` propagate), never a JSX
* element type — `<Rec />` is a compile error by design. Props-free RECs can be
* yielded directly (`yield* Child`); RECs with props use `yield* Child.with(p)`.
* `E` carries the tagged failures its body can raise (`.catch` discharges them);
* `S` carries an unhandled loading obligation (`.suspense` discharges it).
*/
type REC<P, E, R, S> = RecCore<P, E, R, S> & ([Record<never, never>] extends [P] ? RecBareYield<R, S> : unknown);
/**
* Define a React Effect Component. The body is a generator that may `yield*`
* Effect services and effects, `yield* hook(...)` for React hooks, `yield*
* suspend(...)` / `query(...)` for async data, and `yield* Child` / `yield* Child.with(props)` to
* place other RECs.
*
* The returned descriptor is server-safe: the same `mount(...)` renders a
* hook-free body on the server, and the very same value mounts on the client.
* Only a body that itself imports client-only APIs (React hooks) makes its
* *module* client-only — the `rec` wrapper never does.
*/
declare function rec<Eff extends AnyYield, A extends ReactNode>(body: () => Generator<Eff, A, never>): REC<Record<never, never>, ErrorsOf<Eff>, RequirementsOf<Eff>, SuspendsOf<Eff>>;
declare function rec<Eff extends AnyYield, A extends ReactNode, Props extends object>(body: (props: Props) => Generator<Eff, A, never>): REC<Props, ErrorsOf<Eff>, RequirementsOf<Eff>, SuspendsOf<Eff>>;
/** A type error naming the services a runtime is missing for a REC's tree. */
type MissingServices<Missing> = readonly ['effract: runtime is missing', Missing];
/** A type error demanding a loading fallback for a tree that can still suspend. */
type LoadingNotHandled = readonly ['effract: loading not handled — add .suspense(fallback), or mount(layer, root, { loading })'];
/**
* A no-service tree's requirement infers as `unknown` (Effect's requirement
* channel is contravariant, so `never` widens). Normalise that to `never` so a
* runtime-free tree mounts/serves under any layer.
*/
type Effective<R> = [unknown] extends [R] ? never : R;
//#endregion
//#region src/infrastructure/reactivity-core.d.ts
/** Read an atom inside a `derive`/`observe` selector, subscribing to it. */
type Read = <A>(atom: ReadableAtom<A>) => A;
/**
* Run `writes` as one atomic notification wave: atoms may be `set` many times
* inside, but subscribers (and the derived atoms and components downstream) are
* notified once, after it returns. Returns whatever `writes` returns.
*
* ```ts
* batch(() => {
* first.set('Ada');
* last.set('Lovelace');
* }); // one re-render, not two
* ```
*/
declare const batch: <A>(writes: () => A) => A;
/**
* Create a writable reactive atom. Read it with `yield*` in a REC, `.value`
* imperatively (in an event handler or a service method), or `$(atom)` inside
* `derive`/`<Observe>`; write it with `.set` / `.update`. Backed by Effect's
* `AtomRef` for storage, so its value is reachable from anywhere an Effect runs;
* writes that don't change the value (by `Equal`) notify no one, and writes made
* inside {@link batch} are coalesced.
*/
declare const atom: <A>(initial: A) => Atom<A>;
/**
* A keyed collection of atoms — one lazily-created, memoised atom per key, so
* per-entity state (a row, a cart line, a todo) is a family lookup rather than
* one giant atom you slice. `family(key)` returns the same atom for equal keys;
* `make` builds a fresh one the first time a key is seen. `forget`/`clear` drop
* cached entries (e.g. when an entity is deleted). Non-primitive keys need a
* `keyOf` to reduce them to a stable map key.
*
* ```ts
* const quantities = atomFamily((_id: string) => atom(1));
* quantities('sku-1').update((n) => n + 1); // independent per id
* ```
*/
interface AtomFamily<K, A> {
(key: K): A;
/** Drop the cached atom for `key` (the next lookup makes a fresh one). */
forget(key: K): void;
/** Drop every cached atom. */
clear(): void;
}
declare const atomFamily: <K, A>(make: (key: K) => A, keyOf?: (key: K) => unknown) => AtomFamily<K, A>;
/**
* An *async derived* value: it reads atoms and returns an `Effect`, and reads in
* a REC with `yield*` like any other atom — but it *suspends* while the effect
* runs, re-runs when a source atom changes (keyed by the read values, so an
* unchanged source is not refetched), and contributes the same loading obligation
* `S` (plus the effect's `E`/`R`) that {@link query} does. Async derivation, still
* expressed as data in the Effect world:
*
* ```ts
* const price = derive.effect(($) => fetchPrice($(sku))); // suspends; refetches on sku change
* // in a REC: const p = yield* price; // .suspense(...) must handle the loading
* ```
*
* It owns no interpreter machinery of its own — yielding it drives, in order, a
* subscription (so the component re-renders when a source changes) and a keyed
* `query` (so it suspends and refetches). Both are ordinary yieldables, so the
* `E`/`R`/`S` channels flow exactly as a hand-written `query` would.
*/
interface AsyncDerived<A, E, R> {
[Symbol.iterator](): Iterator<Suspensable<A, E, R> | ReadableAtom<ReadonlyArray<unknown>>, A>;
}
/**
* Create a *derived* atom from *several* atoms: a read-only reactive value whose
* `$` tracks exactly the atoms the selector reads, so it recomputes — and its
* readers re-render — precisely when a tracked atom changes. Derived atoms
* compose. Keep derivation here, in the Effect world, not in a component.
*
* For the common case of deriving from a *single* atom, prefer the method form
* {@link ReadableAtom.derive | `atom.derive`} — it hands you the value directly,
* with no `$`: `const count = items.derive((list) => list.length)`.
*
* ```ts
* const total = derive(($) => $(price) * $(qty)); // several sources → the $ form
* ```
*
* {@link deriveWritable | `derive.writable`} adds a two-way variant;
* {@link deriveEffect | `derive.effect`} an async, suspending one.
*/
declare const derive: (<A>(selector: (read: Read) => A) => ReadableAtom<A>) & {
writable: <A>(selector: (read: Read) => A, write: (value: A) => void) => Atom<A>;
effect: <A, E, R>(selector: (read: Read) => Effect.Effect<A, E, R>) => AsyncDerived<A, E, R>;
};
//#endregion
export { SuspendsOf as A, query as B, ReadableAtom as C, RecPlacement as D, RecHandle as E, isAtom as F, isHook as I, isPlacement as L, SuspensableTypeId as M, Yieldable as N, RequirementsOf as O, hook as P, isSuspensable as R, PlacementTypeId as S, RecGenerator as T, suspend as V, AtomTypeId as _, atomFamily as a, Hook as b, CatchHandlers as c, MissingServices as d, REC as f, Atom as g, AnyEffect as h, atom as i, Suspensable as j, Suspends as k, Effective as l, rec as m, AtomFamily as n, batch as o, RecTypeId as p, Read as r, derive as s, AsyncDerived as t, LoadingNotHandled as u, CatchDispatch as v, RecBody as w, HookTypeId as x, ErrorsOf as y, placement as z };
/**
* The reactive atoms — the server-safe half of the reactivity bridge. This
* module carries no `'use client'` and imports no React: an `atom`/`derive` value
* is pure Effect-backed data (a value you can read, write, subscribe to, and
* `yield*`), so a *service* that holds one stays universal. The React binding
* (`observe`, `useAtom`, and the in-render reader) lives in the sibling
* `react/reactivity.tsx`, tagged `'use client'`; only *reading a signal in a
* component* needs React, not owning one in a service.
*/
import type * as Effect from 'effect/Effect';
import * as Equal from 'effect/Equal';
import { AtomRef } from 'effect/unstable/reactivity';
import {
AtomTypeId,
query,
type Atom,
type ReadableAtom,
type Suspensable,
} from '#domain/protocol.ts';
/** Read an atom inside a `derive`/`observe` selector, subscribing to it. */
export type Read = <A>(atom: ReadableAtom<A>) => A;
/** The single-shot iterator that makes an atom `yield*`able: read + subscribe. */
const yieldSelf = <A>(self: ReadableAtom<A>): Iterator<ReadableAtom<A>, A> => {
let yielded = false;
return {
next(sent?: unknown): IteratorResult<ReadableAtom<A>, A> {
if (yielded) {
return { done: true, value: sent as A };
}
yielded = true;
return { done: false, value: self };
},
};
};
/**
* Batching: coalesce a burst of writes into a single notification wave. While a
* `batch` runs, every atom/derived listener that would fire is collected instead
* of called; when the outermost batch ends, each is called exactly once. So a
* derived value that reads two atoms both changed in one batch recomputes once,
* and a component reading them re-renders once, not twice. Batches nest — only
* the outermost flush notifies.
*/
let batchDepth = 0;
const pending = new Set<() => void>();
const flush = (): void => {
// Snapshot + clear before running: a listener may schedule further work, but
// at depth 0 that fires synchronously and must not mutate the set we iterate.
const wave = Array.from(pending);
pending.clear();
for (const listener of wave) {
listener();
}
};
/** Notify a listener set, deferring into the current batch if one is open. */
const notify = (listeners: Set<() => void>): void => {
if (batchDepth > 0) {
for (const listener of listeners) {
pending.add(listener);
}
} else {
// Snapshot: a listener may subscribe/unsubscribe while being notified.
for (const listener of Array.from(listeners)) {
listener();
}
}
};
/**
* Run `writes` as one atomic notification wave: atoms may be `set` many times
* inside, but subscribers (and the derived atoms and components downstream) are
* notified once, after it returns. Returns whatever `writes` returns.
*
* ```ts
* batch(() => {
* first.set('Ada');
* last.set('Lovelace');
* }); // one re-render, not two
* ```
*/
export const batch = <A>(writes: () => A): A => {
batchDepth += 1;
try {
return writes();
} finally {
batchDepth -= 1;
if (batchDepth === 0) {
flush();
}
}
};
/**
* Create a writable reactive atom. Read it with `yield*` in a REC, `.value`
* imperatively (in an event handler or a service method), or `$(atom)` inside
* `derive`/`<Observe>`; write it with `.set` / `.update`. Backed by Effect's
* `AtomRef` for storage, so its value is reachable from anywhere an Effect runs;
* writes that don't change the value (by `Equal`) notify no one, and writes made
* inside {@link batch} are coalesced.
*/
export const atom = <A>(initial: A): Atom<A> => {
const ref = AtomRef.make(initial);
const listeners = new Set<() => void>();
const write = (next: A): void => {
if (Equal.equals(next, ref.value)) {
return; // no-op write — nothing downstream needs to hear about it
}
ref.set(next);
notify(listeners);
};
const self: Atom<A> = {
[AtomTypeId]: true,
get value() {
return ref.value;
},
set: (value) => write(value),
update: (update) => write(update(ref.value)),
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
derive: (f) => deriveReadonly((read) => f(read(self))),
[Symbol.iterator]: () => yieldSelf(self),
};
return self;
};
/**
* A keyed collection of atoms — one lazily-created, memoised atom per key, so
* per-entity state (a row, a cart line, a todo) is a family lookup rather than
* one giant atom you slice. `family(key)` returns the same atom for equal keys;
* `make` builds a fresh one the first time a key is seen. `forget`/`clear` drop
* cached entries (e.g. when an entity is deleted). Non-primitive keys need a
* `keyOf` to reduce them to a stable map key.
*
* ```ts
* const quantities = atomFamily((_id: string) => atom(1));
* quantities('sku-1').update((n) => n + 1); // independent per id
* ```
*/
export interface AtomFamily<K, A> {
(key: K): A;
/** Drop the cached atom for `key` (the next lookup makes a fresh one). */
forget(key: K): void;
/** Drop every cached atom. */
clear(): void;
}
export const atomFamily = <K, A>(
make: (key: K) => A,
keyOf: (key: K) => unknown = (key) => key,
): AtomFamily<K, A> => {
const cache = new Map<unknown, A>();
const family = ((key: K): A => {
const id = keyOf(key);
if (!cache.has(id)) {
cache.set(id, make(key));
}
return cache.get(id) as A;
}) as AtomFamily<K, A>;
family.forget = (key) => {
cache.delete(keyOf(key));
};
family.clear = () => {
cache.clear();
};
return family;
};
/**
* A standalone tracked computation: it evaluates a selector, remembering which
* atoms it read, and — while anyone is subscribed — keeps a memoised value in
* sync by re-running when a tracked atom changes. Read with no subscribers, it
* computes fresh. The engine behind both {@link derive} and `observe`.
*/
export interface Computation<A> {
get(): A;
subscribe(listener: () => void): () => void;
}
export const computation = <A>(selector: (read: Read) => A): Computation<A> => {
const listeners = new Set<() => void>();
let currentDeps: Array<ReadableAtom<unknown>> = [];
let unsubscribes: Array<() => void> = [];
let cached: A;
let hasCached = false;
let tracking = false;
const evaluate = (): { value: A; deps: Array<ReadableAtom<unknown>> } => {
const deps: Array<ReadableAtom<unknown>> = [];
const read: Read = (atomRead) => {
deps.push(atomRead as ReadableAtom<unknown>);
return atomRead.value;
};
return { value: selector(read), deps };
};
const sameDeps = (deps: Array<ReadableAtom<unknown>>): boolean =>
deps.length === currentDeps.length && deps.every((dep, i) => dep === currentDeps[i]);
const listen = (deps: Array<ReadableAtom<unknown>>): void => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
unsubscribes = deps.map((dep) => dep.subscribe(onChange));
currentDeps = deps;
};
function onChange(): void {
const { value, deps } = evaluate();
// Only re-subscribe when the dependency *set* actually changed. Re-subscribing
// on every value change would mutate the notifying atom's listener set while
// it iterates — re-entrant, and for a derived-of-derived chain, an infinite loop.
if (!sameDeps(deps)) {
listen(deps);
}
const changed = !Equal.equals(value, cached);
cached = value;
hasCached = true;
if (changed) {
// Snapshot the listeners: one may subscribe/unsubscribe while being notified.
for (const listener of Array.from(listeners)) {
listener();
}
}
}
return {
// While tracked, `cached` is authoritative. Read with no subscribers, we
// recompute — but return the *same reference* when the value is `Equal`-equal
// to the last, so a caller polling `get()` (React's `getSnapshot`) sees an
// `Object.is`-stable value and does not spin.
get: () => {
if (tracking) {
return cached;
}
const next = evaluate().value;
if (hasCached && Equal.equals(next, cached)) {
return cached;
}
cached = next;
hasCached = true;
return cached;
},
subscribe: (listener) => {
if (listeners.size === 0) {
const first = evaluate();
cached = first.value;
hasCached = true;
tracking = true;
listen(first.deps);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
unsubscribes = [];
currentDeps = [];
tracking = false;
}
};
},
};
};
const deriveReadonly = <A>(selector: (read: Read) => A): ReadableAtom<A> => {
const comp = computation(selector);
const self: ReadableAtom<A> = {
[AtomTypeId]: true,
get value() {
return comp.get();
},
subscribe: (listener) => comp.subscribe(listener),
derive: (f) => deriveReadonly((read) => f(read(self))),
[Symbol.iterator]: () => yieldSelf(self),
};
return self;
};
/**
* A *writable* derived atom: read like a `derive` (tracked, recomputes when a
* source changes), but also `set`/`update`, with the write routed back through
* `write` to the source atoms. For adapters and two-way projections — a form
* field over a model, a unit conversion — where the value both derives from and
* feeds the atoms beneath it.
*
* ```ts
* const fahrenheit = derive.writable(
* ($) => $(celsius) * 1.8 + 32,
* (f) => celsius.set((f - 32) / 1.8),
* );
* ```
*/
const deriveWritable = <A>(selector: (read: Read) => A, write: (value: A) => void): Atom<A> => {
const comp = computation(selector);
const self: Atom<A> = {
[AtomTypeId]: true,
get value() {
return comp.get();
},
set: (value) => write(value),
update: (update) => write(update(comp.get())),
subscribe: (listener) => comp.subscribe(listener),
derive: (f) => deriveReadonly((read) => f(read(self))),
[Symbol.iterator]: () => yieldSelf(self),
};
return self;
};
/**
* An *async derived* value: it reads atoms and returns an `Effect`, and reads in
* a REC with `yield*` like any other atom — but it *suspends* while the effect
* runs, re-runs when a source atom changes (keyed by the read values, so an
* unchanged source is not refetched), and contributes the same loading obligation
* `S` (plus the effect's `E`/`R`) that {@link query} does. Async derivation, still
* expressed as data in the Effect world:
*
* ```ts
* const price = derive.effect(($) => fetchPrice($(sku))); // suspends; refetches on sku change
* // in a REC: const p = yield* price; // .suspense(...) must handle the loading
* ```
*
* It owns no interpreter machinery of its own — yielding it drives, in order, a
* subscription (so the component re-renders when a source changes) and a keyed
* `query` (so it suspends and refetches). Both are ordinary yieldables, so the
* `E`/`R`/`S` channels flow exactly as a hand-written `query` would.
*/
export interface AsyncDerived<A, E, R> {
[Symbol.iterator](): Iterator<Suspensable<A, E, R> | ReadableAtom<ReadonlyArray<unknown>>, A>;
}
const deriveEffect = <A, E, R>(
selector: (read: Read) => Effect.Effect<A, E, R>,
): AsyncDerived<A, E, R> => {
// A stable tracker over exactly the atoms the selector reads: its value is the
// snapshot of their current values, so it recomputes (and, yielded, re-renders
// its component) only on a real change — and that snapshot is the refetch key.
// Building the effect is pure/lazy, so we may run the selector just to track.
const key = deriveReadonly<ReadonlyArray<unknown>>((read) => {
const values: Array<unknown> = [];
selector((atomRead) => {
const value = read(atomRead);
values.push(value);
return value;
});
return values;
});
const plainRead: Read = (atomRead) => atomRead.value;
return {
[Symbol.iterator]() {
let step: 0 | 1 | 2 = 0;
return {
next(
sent?: unknown,
): IteratorResult<Suspensable<A, E, R> | ReadableAtom<ReadonlyArray<unknown>>, A> {
if (step === 0) {
step = 1;
return { done: false, value: key }; // subscribe: re-render when a source changes
}
if (step === 1) {
step = 2;
// Suspend on the effect, keyed by the snapshot just read back, so it
// refetches iff a source value changed.
return {
done: false,
value: query(selector(plainRead), sent as ReadonlyArray<unknown>),
};
}
return { done: true, value: sent as A };
},
};
},
};
};
/**
* Create a *derived* atom from *several* atoms: a read-only reactive value whose
* `$` tracks exactly the atoms the selector reads, so it recomputes — and its
* readers re-render — precisely when a tracked atom changes. Derived atoms
* compose. Keep derivation here, in the Effect world, not in a component.
*
* For the common case of deriving from a *single* atom, prefer the method form
* {@link ReadableAtom.derive | `atom.derive`} — it hands you the value directly,
* with no `$`: `const count = items.derive((list) => list.length)`.
*
* ```ts
* const total = derive(($) => $(price) * $(qty)); // several sources → the $ form
* ```
*
* {@link deriveWritable | `derive.writable`} adds a two-way variant;
* {@link deriveEffect | `derive.effect`} an async, suspending one.
*/
export const derive = Object.assign(deriveReadonly, {
writable: deriveWritable,
effect: deriveEffect,
});
+12
-15

@@ -1,5 +0,4 @@

import { A as suspend, C as Yieldable, D as isSuspensable, E as isPlacement, O as placement, S as SuspensableTypeId, T as isHook, _ as RecPlacement, a as REC, b as SuspendsOf, c as AnyEffect, d as Hook, f as HookTypeId, g as RecHandle, h as RecGenerator, i as MissingServices, k as query, m as RecBody, n as Effective, o as RecTypeId, p as PlacementTypeId, r as LoadingNotHandled, s as rec, t as CatchHandlers, u as ErrorsOf, v as RequirementsOf, w as hook, x as Suspensable, y as Suspends } from "./rec-core-DCYRA4vK.mjs";
import { A as SuspendsOf, B as query, C as ReadableAtom, D as RecPlacement, E as RecHandle, F as isAtom, I as isHook, L as isPlacement, M as SuspensableTypeId, N as Yieldable, O as RequirementsOf, P as hook, R as isSuspensable, S as PlacementTypeId, T as RecGenerator, V as suspend, _ as AtomTypeId, a as atomFamily, b as Hook, c as CatchHandlers, d as MissingServices, f as REC, g as Atom, h as AnyEffect, i as atom, j as Suspensable, k as Suspends, l as Effective, m as rec, n as AtomFamily, o as batch, p as RecTypeId, r as Read, s as derive, t as AsyncDerived, u as LoadingNotHandled, w as RecBody, x as HookTypeId, y as ErrorsOf, z as placement } from "./reactivity-core-qYQtTog0.mjs";
import { ReactNode } from "react";
import * as ManagedRuntime from "effect/ManagedRuntime";
import { AtomRef } from "effect/unstable/reactivity";
import * as Layer from "effect/Layer";

@@ -56,10 +55,8 @@

//#region src/infrastructure/react/reactivity.d.ts
type Ref<A> = AtomRef.AtomRef<A>;
/** Read an atom inside `observe`, subscribing the component to it. */
type Read = <A>(ref: Ref<A>) => A;
/** Create a reactive cell. Sugar for `AtomRef.make`. */
declare const atom: <A>(initial: A) => Ref<A>;
/**
* Subscribe to a derived view over one or more atoms. Re-renders precisely when
* a read atom changes.
* Subscribe a component to an inline derived view over one or more atoms. Re-renders
* precisely when a read atom changes. This is the escape hatch for an ad-hoc reactive
* expression in a component; prefer reading a single atom with {@link useAtomValue}
* (or `yield*` in a REC), and deriving shared values with `atom.derive` / `derive` in
* a service. `<Observe>` is the same thing, inline in JSX.
*

@@ -72,11 +69,11 @@ * ```tsx

/** Read a single atom's value, subscribing the component to it. */
declare const useAtomValue: <A>(ref: Ref<A>) => A;
/** A stable setter for an atom, supporting both values and updater functions. */
declare const useAtomSet: <A>(ref: Ref<A>) => ((value: A | ((prev: A) => A)) => void);
declare const useAtomValue: <A>(atomRead: ReadableAtom<A>) => A;
/** A stable setter for a writable atom, supporting both values and updater functions. */
declare const useAtomSet: <A>(atomWrite: Atom<A>) => ((value: A | ((prev: A) => A)) => void);
/** Read and write a single atom — the `useState` shape, backed by Effect. */
declare const useAtom: <A>(ref: Ref<A>) => readonly [A, (value: A | ((prev: A) => A)) => void];
declare const useAtom: <A>(atomWrite: Atom<A>) => readonly [A, (value: A | ((prev: A) => A)) => void];
interface ObserveProps<A extends ReactNode> {
readonly children: (read: Read) => A;
}
/** The render-prop form of {@link observe}. */
/** The render-prop form of {@link observe}, for inline reactive values in JSX. */
declare const Observe: <A extends ReactNode>({

@@ -101,2 +98,2 @@ children

//#endregion
export { type AnyEffect, type CatchHandlers, type Effective, type ErrorsOf, type Hook, HookTypeId, type LoadingNotHandled, type MissingServices, type MountOptions, Observe, type ObserveProps, PlacementTypeId, type REC, type Read, type RecBody, type RecGenerator, type RecHandle, type RecPlacement, RecTypeId, type RequirementsOf, Runtime, type RuntimeProps, type Suspends, type SuspendsOf, type Suspensable, SuspensableTypeId, VERSION, type Yieldable, atom, hook, isHook, isPlacement, isSuspensable, mount, observe, placement, query, rec, suspend, useAtom, useAtomSet, useAtomValue, useEffractRuntime };
export { type AnyEffect, type AsyncDerived, type Atom, type AtomFamily, AtomTypeId, type CatchHandlers, type Effective, type ErrorsOf, type Hook, HookTypeId, type LoadingNotHandled, type MissingServices, type MountOptions, Observe, type ObserveProps, PlacementTypeId, type REC, type Read, type ReadableAtom, type RecBody, type RecGenerator, type RecHandle, type RecPlacement, RecTypeId, type RequirementsOf, Runtime, type RuntimeProps, type Suspends, type SuspendsOf, type Suspensable, SuspensableTypeId, VERSION, type Yieldable, atom, atomFamily, batch, derive, hook, isAtom, isHook, isPlacement, isSuspensable, mount, observe, placement, query, rec, suspend, useAtom, useAtomSet, useAtomValue, useEffractRuntime };

@@ -1,2 +0,2 @@

import { a as SuspensableTypeId, c as isPlacement, d as query, f as suspend, i as PlacementTypeId, l as isSuspensable, n as rec, o as hook, r as HookTypeId, s as isHook, t as RecTypeId, u as placement } from "./rec-core-JpS6GRJm.mjs";
import { _ as placement, a as derive, c as AtomTypeId, d as SuspensableTypeId, f as hook, g as isSuspensable, h as isPlacement, i as computation, l as HookTypeId, m as isHook, n as atomFamily, o as RecTypeId, p as isAtom, r as batch, s as rec, t as atom, u as PlacementTypeId, v as query, y as suspend } from "./reactivity-core-Ceq7GLk8.mjs";
import { Suspense, createContext, createElement, use, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";

@@ -8,4 +8,2 @@ import * as Cause from "effect/Cause";

import { jsx } from "react/jsx-runtime";
import * as Equal from "effect/Equal";
import { AtomRef } from "effect/unstable/reactivity";
//#region src/application/interpreter.ts

@@ -70,2 +68,3 @@ /**

else if (isSuspensable(instruction)) result = deps.suspensableResolver.resolve(instruction, state.queryIndex++);
else if (isAtom(instruction)) result = deps.reader.read(instruction);
else result = resolveEffect(instruction, deps, state);

@@ -103,2 +102,68 @@ step = gen.next(result);

//#endregion
//#region src/infrastructure/react/reactivity.tsx
/**
* The React binding for reactive atoms — the client half of the reactivity
* bridge. The atoms themselves (`atom`, `derive`) are server-safe and live in
* `../reactivity-core`; this module adds the hooks that read them in a component
* (`observe`, `<Observe>`, `useAtom*`) and the in-render reader the interpreter
* uses for a yielded atom. Owning a signal in a service needs no React; only
* *reading one in the render pass* does — which is why this half is `'use client'`.
*
* const n = useAtomValue(count); // read one atom (re-renders on change)
* const [n, setN] = useAtom(count); // read + write, useState-shaped
* <Observe>{($) => <b>{$(count)}</b>}</Observe> // an inline reactive expression in JSX
*/
/**
* Subscribe a component to an inline derived view over one or more atoms. Re-renders
* precisely when a read atom changes. This is the escape hatch for an ad-hoc reactive
* expression in a component; prefer reading a single atom with {@link useAtomValue}
* (or `yield*` in a REC), and deriving shared values with `atom.derive` / `derive` in
* a service. `<Observe>` is the same thing, inline in JSX.
*
* ```tsx
* const doubled = observe(($) => $(count) * 2);
* ```
*/
const observe = (selector) => {
const selectorRef = useRef(selector);
selectorRef.current = selector;
const compRef = useRef(null);
if (compRef.current === null) compRef.current = computation((read) => selectorRef.current(read));
const comp = compRef.current;
const subscribe = useCallback((onStoreChange) => comp.subscribe(onStoreChange), [comp]);
const getSnapshot = useCallback(() => comp.get(), [comp]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
/** Read a single atom's value, subscribing the component to it. */
const useAtomValue = (atomRead) => {
const subscribe = useCallback((onStoreChange) => atomRead.subscribe(onStoreChange), [atomRead]);
const getSnapshot = useCallback(() => atomRead.value, [atomRead]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
/** A stable setter for a writable atom, supporting both values and updater functions. */
const useAtomSet = (atomWrite) => useCallback((value) => {
if (typeof value === "function") atomWrite.update(value);
else atomWrite.set(value);
}, [atomWrite]);
/** Read and write a single atom — the `useState` shape, backed by Effect. */
const useAtom = (atomWrite) => [useAtomValue(atomWrite), useAtomSet(atomWrite)];
/** The render-prop form of {@link observe}, for inline reactive values in JSX. */
const Observe = ({ children }) => observe(children);
/**
* The reader the interpreter uses to resolve a yielded atom: read its current
* value and subscribe this render to it (React's `useSyncExternalStore`). Called
* once per atom read during the render pass, in the body's stable order.
*/
const atomReader = { read: (atomRead) => useSyncExternalStore(atomRead.subscribe, getValue(atomRead), getValue(atomRead)) };
const snapshots = /* @__PURE__ */ new WeakMap();
/** A per-atom stable `getSnapshot` (`() => atom.value`), memoised by atom identity. */
const getValue = (atomRead) => {
let snapshot = snapshots.get(atomRead);
if (snapshot === void 0) {
snapshot = () => atomRead.value;
snapshots.set(atomRead, snapshot);
}
return snapshot;
};
//#endregion
//#region src/infrastructure/react/suspensable-store.ts

@@ -364,2 +429,3 @@ /** How long an unclaimed entry lingers after settling before it is swept. */

suspensableResolver: resolver,
reader: atomReader,
placer: clientPlacer

@@ -396,100 +462,2 @@ }, handle.catchHandlers);

//#endregion
//#region src/infrastructure/react/reactivity.tsx
/**
* The signals bridge. Effect's reactive cell is `AtomRef`; this binds it to
* React so a component re-renders precisely when — and only when — an atom it
* actually read changes.
*
* observe($ => $(count) * 2) // a hook: read + auto-subscribe
* <Observe>{$ => <b>{$(count)}</b>}</Observe> // the same, as an element
* const [n, setN] = useAtom(count) // read + write a single atom
*
* `observe` tracks exactly the atoms touched during its selector and subscribes
* to that set, re-tracking on every change so dynamic dependencies stay
* correct. No `Effect.runSync` at the call site, no manual dependency arrays.
*/
/** Create a reactive cell. Sugar for `AtomRef.make`. */
const atom = (initial) => AtomRef.make(initial);
const depsEqual = (a, b) => {
if (a.size !== b.size) return false;
for (const [ref, value] of a) if (!b.has(ref) || !Equal.equals(value, b.get(ref))) return false;
return true;
};
/**
* Run the selector, tracking which atoms it reads. Returns a *stable* reference
* when the tracked atoms and their values are unchanged, so it is safe to call
* from `getSnapshot` without provoking a render loop.
*/
const compute = (state) => {
const nextDeps = /* @__PURE__ */ new Map();
const read = (ref) => {
const value = ref.value;
nextDeps.set(ref, value);
return value;
};
const next = state.selector(read);
if (state.initialized && depsEqual(state.deps, nextDeps)) {
state.deps = nextDeps;
return state.value;
}
state.deps = nextDeps;
state.value = next;
state.initialized = true;
return next;
};
/**
* Subscribe to a derived view over one or more atoms. Re-renders precisely when
* a read atom changes.
*
* ```tsx
* const doubled = observe(($) => $(count) * 2);
* ```
*/
const observe = (selector) => {
const stateRef = useRef(null);
if (stateRef.current === null) stateRef.current = {
selector,
deps: /* @__PURE__ */ new Map(),
value: void 0,
initialized: false
};
stateRef.current.selector = selector;
const subscribe = useCallback((onStoreChange) => {
const state = stateRef.current;
if (state === null) return () => {};
let unsubscribes = [];
const resubscribe = () => {
for (const unsub of unsubscribes) unsub();
unsubscribes = [...state.deps.keys()].map((ref) => ref.subscribe(handleChange));
};
function handleChange() {
compute(state);
resubscribe();
onStoreChange();
}
compute(state);
resubscribe();
return () => {
for (const unsub of unsubscribes) unsub();
};
}, []);
const getSnapshot = useCallback(() => compute(stateRef.current), []);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
/** Read a single atom's value, subscribing the component to it. */
const useAtomValue = (ref) => {
const subscribe = useCallback((onStoreChange) => ref.subscribe(onStoreChange), [ref]);
const getSnapshot = useCallback(() => ref.value, [ref]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
/** A stable setter for an atom, supporting both values and updater functions. */
const useAtomSet = (ref) => useCallback((value) => {
if (typeof value === "function") ref.update(value);
else ref.set(value);
}, [ref]);
/** Read and write a single atom — the `useState` shape, backed by Effect. */
const useAtom = (ref) => [useAtomValue(ref), useAtomSet(ref)];
/** The render-prop form of {@link observe}. */
const Observe = ({ children }) => observe(children);
//#endregion
//#region src/index.client.ts

@@ -510,2 +478,2 @@ /**

//#endregion
export { HookTypeId, Observe, PlacementTypeId, RecTypeId, Runtime, SuspensableTypeId, VERSION, atom, hook, isHook, isPlacement, isSuspensable, mount, observe, placement, query, rec, suspend, useAtom, useAtomSet, useAtomValue, useEffractRuntime };
export { AtomTypeId, HookTypeId, Observe, PlacementTypeId, RecTypeId, Runtime, SuspensableTypeId, VERSION, atom, atomFamily, batch, derive, hook, isAtom, isHook, isPlacement, isSuspensable, mount, observe, placement, query, rec, suspend, useAtom, useAtomSet, useAtomValue, useEffractRuntime };

@@ -1,2 +0,2 @@

import { A as suspend, C as Yieldable, D as isSuspensable, E as isPlacement, O as placement, S as SuspensableTypeId, _ as RecPlacement, a as REC, b as SuspendsOf, c as AnyEffect, g as RecHandle, h as RecGenerator, i as MissingServices, k as query, l as CatchDispatch, m as RecBody, n as Effective, o as RecTypeId, p as PlacementTypeId, s as rec, t as CatchHandlers, u as ErrorsOf, v as RequirementsOf, x as Suspensable, y as Suspends } from "./rec-core-DCYRA4vK.mjs";
import { A as SuspendsOf, B as query, C as ReadableAtom, D as RecPlacement, E as RecHandle, F as isAtom, L as isPlacement, M as SuspensableTypeId, N as Yieldable, O as RequirementsOf, R as isSuspensable, S as PlacementTypeId, T as RecGenerator, V as suspend, _ as AtomTypeId, a as atomFamily, c as CatchHandlers, d as MissingServices, f as REC, g as Atom, h as AnyEffect, i as atom, j as Suspensable, k as Suspends, l as Effective, m as rec, n as AtomFamily, o as batch, p as RecTypeId, r as Read, s as derive, t as AsyncDerived, v as CatchDispatch, w as RecBody, y as ErrorsOf, z as placement } from "./reactivity-core-qYQtTog0.mjs";
import { ReactNode } from "react";

@@ -57,5 +57,7 @@ import * as Layer from "effect/Layer";

*
* Client-only APIs (`Runtime`, `observe`, `atom`, …) are intentionally absent
* Client-only APIs (`Runtime`, `observe`, `useAtom`, …) are intentionally absent
* here — a component that needs them is a client island, and lives in the client
* graph.
* graph. The state *primitives* `atom`/`derive` are not client-only, though: they
* are pure Effect-backed state, so a universal service that holds one stays
* server-safe and they are exported here too.
*

@@ -66,2 +68,2 @@ * @packageDocumentation

//#endregion
export { type AnyEffect, type CatchHandlers, type Effective, type ErrorsOf, type MissingServices, PlacementTypeId, type REC, type RecBody, type RecGenerator, type RecHandle, type RecPlacement, RecTypeId, type RequirementsOf, type RunEffect, type Suspends, type SuspendsOf, type Suspensable, SuspensableTypeId, VERSION, type Yieldable, driveServerRec, isPlacement, isSuspensable, mount, placement, query, rec, suspend };
export { type AnyEffect, type AsyncDerived, type Atom, type AtomFamily, AtomTypeId, type CatchHandlers, type Effective, type ErrorsOf, type MissingServices, PlacementTypeId, type REC, type Read, type ReadableAtom, type RecBody, type RecGenerator, type RecHandle, type RecPlacement, RecTypeId, type RequirementsOf, type RunEffect, type Suspends, type SuspendsOf, type Suspensable, SuspensableTypeId, VERSION, type Yieldable, atom, atomFamily, batch, derive, driveServerRec, isAtom, isPlacement, isSuspensable, mount, placement, query, rec, suspend };

@@ -1,2 +0,2 @@

import { a as SuspensableTypeId, c as isPlacement, d as query, f as suspend, i as PlacementTypeId, l as isSuspensable, n as rec, s as isHook, t as RecTypeId, u as placement } from "./rec-core-JpS6GRJm.mjs";
import { _ as placement, a as derive, c as AtomTypeId, d as SuspensableTypeId, g as isSuspensable, h as isPlacement, m as isHook, n as atomFamily, o as RecTypeId, p as isAtom, r as batch, s as rec, t as atom, u as PlacementTypeId, v as query, y as suspend } from "./reactivity-core-Ceq7GLk8.mjs";
import * as ManagedRuntime from "effect/ManagedRuntime";

@@ -44,2 +44,6 @@ //#region src/application/server-driver.ts

}
if (isAtom(instruction)) {
step = gen.next(instruction.value);
continue;
}
const effect = isSuspensable(instruction) ? instruction.effect : instruction;

@@ -126,5 +130,7 @@ let value;

*
* Client-only APIs (`Runtime`, `observe`, `atom`, …) are intentionally absent
* Client-only APIs (`Runtime`, `observe`, `useAtom`, …) are intentionally absent
* here — a component that needs them is a client island, and lives in the client
* graph.
* graph. The state *primitives* `atom`/`derive` are not client-only, though: they
* are pure Effect-backed state, so a universal service that holds one stays
* server-safe and they are exported here too.
*

@@ -135,2 +141,2 @@ * @packageDocumentation

//#endregion
export { PlacementTypeId, RecTypeId, SuspensableTypeId, VERSION, driveServerRec, isPlacement, isSuspensable, mount, placement, query, rec, suspend };
export { AtomTypeId, PlacementTypeId, RecTypeId, SuspensableTypeId, VERSION, atom, atomFamily, batch, derive, driveServerRec, isAtom, isPlacement, isSuspensable, mount, placement, query, rec, suspend };
{
"name": "@tmonier/effract",
"version": "0.4.0",
"version": "0.5.0",
"description": "React components written as Effect programs. yield* services and React hooks in one render pass; run the same component anywhere.",

@@ -5,0 +5,0 @@ "keywords": [

@@ -18,2 +18,3 @@ /**

Placer,
Reader,
SuspensableResolver,

@@ -55,2 +56,5 @@ RenderCache,

/** A trivial reader: the current value, no subscription (enough for these tests). */
const staticReader: Reader = { read: (atomRead) => atomRead.value };
const makeDeps = (

@@ -64,2 +68,3 @@ layer: Layer.Layer<never, never, never>,

suspensableResolver: neverResolveSuspensable,
reader: staticReader,
placer: neverPlace,

@@ -135,2 +140,3 @@ });

suspensableResolver: neverResolveSuspensable,
reader: staticReader,
suspender: { use: () => 99 as never },

@@ -169,2 +175,3 @@ placer: neverPlace,

suspensableResolver: neverResolveSuspensable,
reader: staticReader,
placer: neverPlace,

@@ -171,0 +178,0 @@ }),

@@ -21,2 +21,3 @@ /**

import {
isAtom,
isHook,

@@ -97,2 +98,5 @@ isPlacement,

result = deps.suspensableResolver.resolve(instruction, state.queryIndex++);
} else if (isAtom(instruction)) {
// Reactive state: read the atom's value and subscribe this render to it.
result = deps.reader.read(instruction);
} else {

@@ -99,0 +103,0 @@ result = resolveEffect(instruction, deps, state);

@@ -8,3 +8,3 @@ /**

import type * as Exit from 'effect/Exit';
import type { AnyEffect, RecPlacement, Suspensable } from '#domain/protocol.ts';
import type { AnyEffect, ReadableAtom, RecPlacement, Suspensable } from '#domain/protocol.ts';

@@ -76,2 +76,13 @@ /**

/**
* Reads a yielded reactive atom to its current value and subscribes the render
* to it. Injected because the subscription is React's (`useSyncExternalStore`) —
* the interpreter stays React-free and just asks for the value. Called once per
* atom read, in the body's deterministic order, so the underlying hook keeps a
* stable order across renders.
*/
export interface Reader {
read<A>(atom: ReadableAtom<A>): A;
}
export interface InterpreterDeps {

@@ -82,3 +93,4 @@ readonly executor: Executor;

readonly suspensableResolver: SuspensableResolver;
readonly reader: Reader;
readonly placer: Placer;
}

@@ -13,2 +13,3 @@ /**

import {
isAtom,
isHook,

@@ -71,2 +72,8 @@ isPlacement,

}
if (isAtom(instruction)) {
// Reactive state has no live subscription on the server — read its current
// value. (Atom-holding services are client concerns; this is the safe read.)
step = gen.next(instruction.value);
continue;
}
// A suspensable is its effect here; an effect is itself. Either way, run and catch.

@@ -73,0 +80,0 @@ const effect = isSuspensable(instruction) ? instruction.effect : instruction;

@@ -167,3 +167,47 @@ /**

/** Brand identifying a reactive atom instruction. */
export const AtomTypeId = Symbol.for('@tmonier/effract/Atom');
export type AtomTypeId = typeof AtomTypeId;
/**
* The fifth kind of yieldable: a *reactive atom read*. Reactive state lives in
* the Effect world — an atom, usually held by a service. Yielding one reads its
* current value *and* subscribes the component, so it re-renders precisely when
* that atom changes: `const n = yield* count`. It carries no `E`/`R`/`S` — a
* read is synchronous, needs nothing, and cannot fail. Derived atoms
* (`derive(...)`) are `ReadableAtom`s too, so they read and yield identically.
* The concrete atom is built in the React binding; the domain only names the
* contract — a value you can read, subscribe to, and `yield*`.
*/
export interface ReadableAtom<out A> {
readonly [AtomTypeId]: true;
/** The current value — read imperatively (in an event handler or a method). */
readonly value: A;
/** Subscribe to changes; returns an unsubscribe. Backs the in-render read. */
subscribe(listener: () => void): () => void;
/**
* Derive a new read-only atom from *this one's* value — the ergonomic,
* single-source form of `derive`. The callback receives the value directly (no
* `$`), and the result recomputes when this atom changes; chain it freely.
* For a value computed from *several* atoms, reach for the free `derive(($) => …)`.
*
* ```ts
* const count = items.derive((list) => list.length);
* ```
*/
derive<B>(f: (value: A) => B): ReadableAtom<B>;
[Symbol.iterator](): Iterator<ReadableAtom<A>, A>;
}
/** A writable reactive atom — a {@link ReadableAtom} you can also `set`/`update`. */
export interface Atom<in out A> extends ReadableAtom<A> {
set(value: A): void;
update(update: (previous: A) => A): void;
}
/** Type guard: is this yielded instruction a reactive atom read? */
export const isAtom = (u: unknown): u is ReadableAtom<unknown> =>
typeof u === 'object' && u !== null && AtomTypeId in u;
/**
* The phantom marker of an *unhandled loading obligation*. A body that yields a

@@ -183,3 +227,3 @@ * {@link Suspensable} (or places a child that still carries one) has this in its

/** Everything a component body may `yield*`: an Effect, a hook, a child placement, or a query. */
/** Everything a body may `yield*`: an Effect, a hook, a placement, a query, or an atom read. */
export type Yieldable<A> =

@@ -189,3 +233,4 @@ | Effect.Effect<A, unknown, unknown>

| RecPlacement<A, unknown, unknown>
| Suspensable<A, unknown, unknown>;
| Suspensable<A, unknown, unknown>
| ReadableAtom<A>;

@@ -197,3 +242,4 @@ /** The generator a React Effect Component body produces. */

| RecPlacement<unknown, unknown, unknown>
| Suspensable<unknown, unknown, unknown>,
| Suspensable<unknown, unknown, unknown>
| ReadableAtom<unknown>,
A,

@@ -200,0 +246,0 @@ unknown

@@ -28,2 +28,4 @@ /**

SuspensableTypeId,
isAtom,
AtomTypeId,
} from '#domain/protocol.ts';

@@ -35,2 +37,4 @@ export type {

Suspends,
Atom,
ReadableAtom,
Yieldable,

@@ -66,6 +70,10 @@ RecBody,

// --- reactivity ---
// `atom` / `derive` / `atomFamily` / `batch` are server-safe (a service can hold
// and drive them); the hooks that read them in a component are client-only, like
// `hook` itself.
export { atom, derive, atomFamily, batch } from '#infrastructure/reactivity-core.ts';
export type { Read, AtomFamily, AsyncDerived } from '#infrastructure/reactivity-core.ts';
export {
observe,
Observe,
atom,
useAtom,

@@ -75,2 +83,2 @@ useAtomValue,

} from '#infrastructure/react/reactivity.tsx';
export type { Read, ObserveProps } from '#infrastructure/react/reactivity.tsx';
export type { ObserveProps } from '#infrastructure/react/reactivity.tsx';

@@ -19,4 +19,20 @@ /**

it('exports the server-safe reactive state primitives', () => {
// `atom`/`derive` are pure Effect-backed state — no React — so a *universal*
// service that holds reactive state resolves under the `react-server`
// condition too. Only the hooks that *read* an atom in a component are absent.
expect(typeof server.atom).toBe('function');
expect(typeof server.derive).toBe('function');
});
it('does NOT export client-only APIs — they cannot exist in a Server Component', () => {
for (const name of ['hook', 'observe', 'atom', 'useAtom', 'useAtomValue', 'Runtime', 'view']) {
for (const name of [
'hook',
'observe',
'Observe',
'useAtom',
'useAtomValue',
'Runtime',
'view',
]) {
expect(name in server, `${name} must not be exported from the server entry`).toBe(false);

@@ -23,0 +39,0 @@ }

@@ -12,5 +12,7 @@ /**

*
* Client-only APIs (`Runtime`, `observe`, `atom`, …) are intentionally absent
* Client-only APIs (`Runtime`, `observe`, `useAtom`, …) are intentionally absent
* here — a component that needs them is a client island, and lives in the client
* graph.
* graph. The state *primitives* `atom`/`derive` are not client-only, though: they
* are pure Effect-backed state, so a universal service that holds one stays
* server-safe and they are exported here too.
*

@@ -26,4 +28,5 @@ * @packageDocumentation

// in the server graph: reach for it in a Server Component and it is a compile
// error ("hook is not exported"), not a runtime surprise. (`observe`/`atom` are
// absent for the same reason — they live only in the client entry.)
// error ("hook is not exported"), not a runtime surprise. (`observe`/`useAtom`
// are absent for the same reason — they live only in the client entry. `atom`
// and `derive`, being React-free state, are exported below.)
// `suspend`/`query` *are* exported: an async dependency has a sensible server

@@ -40,2 +43,4 @@ // meaning (its effect is awaited inline), so a universal REC that uses one stays

SuspensableTypeId,
isAtom,
AtomTypeId,
} from '#domain/protocol.ts';

@@ -46,2 +51,4 @@ export type {

Suspends,
Atom,
ReadableAtom,
Yieldable,

@@ -57,2 +64,8 @@ RecBody,

// `atom` / `derive` are server-safe (pure Effect-backed state), so a universal
// service that holds reactive state stays server-safe. The hooks that *read* them
// in a component are client-only and absent here (like `hook`/`observe`).
export { atom, derive, atomFamily, batch } from '#infrastructure/reactivity-core.ts';
export type { Read, AtomFamily, AsyncDerived } from '#infrastructure/reactivity-core.ts';
// --- components ---

@@ -59,0 +72,0 @@ export { rec, RecTypeId } from '#infrastructure/rec-core.tsx';

@@ -8,6 +8,24 @@ /**

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Observe, atom, observe, useAtom } from '../../index.client.ts';
import * as Effect from 'effect/Effect';
import * as Layer from 'effect/Layer';
import {
Observe,
atom,
atomFamily,
batch,
derive,
mount,
observe,
rec,
useAtom,
} from '../../index.client.ts';
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true);
const flush = (): Promise<void> =>
act(async () => {
await Promise.resolve();
await Promise.resolve();
});
let container: HTMLDivElement;

@@ -104,2 +122,268 @@ beforeEach(() => {

});
it('yield* atom reads reactive state in a REC and re-renders on change', async () => {
const count = atom(1);
const Counter = rec(function* () {
const n = yield* count; // read + subscribe — no hook(useAtomValue(...))
return <span>{n}</span>;
});
const root = createRoot(container);
await act(async () => root.render(mount(Layer.empty, Counter)));
expect(container.textContent).toBe('1');
await act(async () => count.set(5));
expect(container.textContent).toBe('5');
await act(async () => root.unmount());
});
it('derive computes from atoms, updates on change, and composes', async () => {
const price = atom(10);
const qty = atom(2);
const subtotal = derive(($) => $(price) * $(qty));
const withTax = derive(($) => Math.round($(subtotal) * 1.1)); // derived-of-derived
const Total = rec(function* () {
const total = yield* withTax;
return <span>{total}</span>;
});
const root = createRoot(container);
await act(async () => root.render(mount(Layer.empty, Total)));
expect(container.textContent).toBe('22'); // 10*2=20 → *1.1=22
await act(async () => qty.set(3));
expect(container.textContent).toBe('33'); // 30 → 33
await act(async () => price.set(20));
expect(container.textContent).toBe('66'); // 60 → 66
await act(async () => root.unmount());
});
it('derive.value reads imperatively (no component, no subscription)', () => {
const n = atom(3);
const doubled = derive(($) => $(n) * 2);
expect(doubled.value).toBe(6);
n.set(5);
expect(doubled.value).toBe(10);
});
it('atom.derive derives from one atom without $, recomputes, and chains', () => {
const items = atom<ReadonlyArray<{ price: number }>>([{ price: 3 }, { price: 4 }]);
const count = items.derive((list) => list.length); // the value, handed over directly
const total = items.derive((list) => list.reduce((n, item) => n + item.price, 0));
const withTax = total.derive((t) => Math.round(t * 1.1)); // chains off a derived atom
expect(count.value).toBe(2);
expect(total.value).toBe(7);
expect(withTax.value).toBe(8); // 7 * 1.1 = 7.7 → 8
items.update((list) => [...list, { price: 3 }]);
expect(count.value).toBe(3);
expect(total.value).toBe(10);
expect(withTax.value).toBe(11); // 10 * 1.1 = 11
});
it('an unchanged write (by Equal) notifies no one', () => {
const n = atom(1);
let notifications = 0;
const unsub = n.subscribe(() => {
notifications += 1;
});
n.set(1); // same value — no-op
expect(notifications).toBe(0);
n.set(2);
expect(notifications).toBe(1);
unsub();
});
it('batch coalesces writes into a single notification wave', () => {
const a = atom(1);
const b = atom(2);
const sum = derive(($) => $(a) + $(b));
let notifications = 0;
const unsub = sum.subscribe(() => {
notifications += 1;
});
// Without a batch: two writes → two recomputations, two notifications.
a.set(10);
b.set(20);
expect(notifications).toBe(2);
expect(sum.value).toBe(30);
// With a batch: two writes → one recomputation, one notification.
notifications = 0;
batch(() => {
a.set(100);
b.set(200);
});
expect(notifications).toBe(1);
expect(sum.value).toBe(300);
unsub();
});
it('atomFamily memoises one independent atom per key', () => {
const quantities = atomFamily((_id: string) => atom(1));
const first = quantities('sku-1');
expect(quantities('sku-1')).toBe(first); // same key → same atom
expect(quantities('sku-2')).not.toBe(first); // different key → its own atom
first.update((n) => n + 4);
expect(quantities('sku-1').value).toBe(5);
expect(quantities('sku-2').value).toBe(1); // independent
quantities.forget('sku-1');
expect(quantities('sku-1')).not.toBe(first); // forgotten → a fresh atom
expect(quantities('sku-1').value).toBe(1);
});
it('derive.writable reads a derived value and writes back to its sources', () => {
const celsius = atom(0);
const fahrenheit = derive.writable(
($) => $(celsius) * 1.8 + 32,
(f) => celsius.set((f - 32) / 1.8),
);
expect(fahrenheit.value).toBe(32);
celsius.set(100);
expect(fahrenheit.value).toBe(212);
fahrenheit.set(32); // write flows back to the source
expect(celsius.value).toBe(0);
fahrenheit.update((f) => f + 18); // 32 → 50 → celsius 10
expect(celsius.value).toBe(10);
});
it('derive.writable is a reactive atom a component can yield and drive', async () => {
const celsius = atom(0);
const fahrenheit = derive.writable(
($) => $(celsius) * 1.8 + 32,
(f) => celsius.set((f - 32) / 1.8),
);
const Thermostat = rec(function* () {
const f = yield* fahrenheit;
return (
<button type="button" onClick={() => fahrenheit.set(f + 18)}>
{f}
</button>
);
});
const root = createRoot(container);
await act(async () => root.render(mount(Layer.empty, Thermostat)));
expect(container.textContent).toBe('32');
await act(async () => {
container.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.textContent).toBe('50'); // +18°F → celsius 10 → 50°F
expect(celsius.value).toBe(10);
await act(async () => root.unmount());
});
it('derive.effect suspends, resolves, and refetches when a source atom changes', async () => {
const calls: number[] = [];
// A per-id gate so we can observe the loading frame deterministically.
const gates = new Map<number, { promise: Promise<number>; settle: (n: number) => void }>();
const gateFor = (id: number): { promise: Promise<number>; settle: (n: number) => void } => {
const existing = gates.get(id);
if (existing) {
return existing;
}
let settle: (n: number) => void = () => {};
const promise = new Promise<number>((resolve) => {
settle = resolve;
});
const gate = { promise, settle };
gates.set(id, gate);
return gate;
};
const sku = atom(1);
// Read the atom *synchronously* in the selector (so it is tracked), then use
// the value inside the effect.
const price = derive.effect(($) => {
const id = $(sku);
return Effect.promise(() => {
calls.push(id);
return gateFor(id).promise;
});
});
const Price = rec(function* () {
const p = yield* price;
return <span data-x="price">{p}</span>;
}).suspense(<i>loading</i>);
const root = createRoot(container);
await act(async () => root.render(mount(Layer.empty, Price)));
expect(container.textContent).toContain('loading'); // suspended on the effect
expect(calls).toEqual([1]);
await act(async () => {
gateFor(1).settle(10);
await gateFor(1).promise;
});
await flush();
expect(container.querySelector('[data-x="price"]')?.textContent).toBe('10');
// A source change re-renders (it subscribed) and refetches, keyed by the new value.
await act(async () => sku.set(3));
await flush();
expect(calls).toEqual([1, 3]);
await act(async () => {
gateFor(3).settle(30);
await gateFor(3).promise;
});
await flush();
expect(container.querySelector('[data-x="price"]')?.textContent).toBe('30');
await act(async () => root.unmount());
});
it('derive.effect does not refetch when a source is written its current value', async () => {
const calls: number[] = [];
const sku = atom(2);
const price = derive.effect(($) => {
const id = $(sku);
return Effect.promise(() => {
calls.push(id);
return Promise.resolve(id * 10);
});
});
const Price = rec(function* () {
const p = yield* price;
return <span>{p}</span>;
}).suspense(<i>loading</i>);
const root = createRoot(container);
await act(async () => root.render(mount(Layer.empty, Price)));
await flush();
expect(container.textContent).toBe('20');
expect(calls).toEqual([2]);
// Same value → no notification, no re-render, no refetch.
await act(async () => sku.set(2));
await flush();
expect(calls).toEqual([2]);
await act(async () => root.unmount());
});
});
// --- type-level: derive.effect carries the loading obligation (checked by tsgo) ---
{
const sku = atom(1);
const AsyncPrice = rec(function* () {
const p = yield* derive.effect(($) => Effect.succeed($(sku) * 10));
return <i>{p}</i>;
});
// @ts-expect-error effract: loading not handled — derive.effect suspends
void mount(Layer.empty, AsyncPrice);
// ✓ discharged with .suspense:
void mount(Layer.empty, AsyncPrice.suspense(<i>loading</i>));
}
'use client';
/**
* The signals bridge. Effect's reactive cell is `AtomRef`; this binds it to
* React so a component re-renders precisely when — and only when — an atom it
* actually read changes.
* The React binding for reactive atoms — the client half of the reactivity
* bridge. The atoms themselves (`atom`, `derive`) are server-safe and live in
* `../reactivity-core`; this module adds the hooks that read them in a component
* (`observe`, `<Observe>`, `useAtom*`) and the in-render reader the interpreter
* uses for a yielded atom. Owning a signal in a service needs no React; only
* *reading one in the render pass* does — which is why this half is `'use client'`.
*
* observe($ => $(count) * 2) // a hook: read + auto-subscribe
* <Observe>{$ => <b>{$(count)}</b>}</Observe> // the same, as an element
* const [n, setN] = useAtom(count) // read + write a single atom
*
* `observe` tracks exactly the atoms touched during its selector and subscribes
* to that set, re-tracking on every change so dynamic dependencies stay
* correct. No `Effect.runSync` at the call site, no manual dependency arrays.
* const n = useAtomValue(count); // read one atom (re-renders on change)
* const [n, setN] = useAtom(count); // read + write, useState-shaped
* <Observe>{($) => <b>{$(count)}</b>}</Observe> // an inline reactive expression in JSX
*/
import { useCallback, useRef, useSyncExternalStore, type ReactNode } from 'react';
import * as Equal from 'effect/Equal';
import { AtomRef } from 'effect/unstable/reactivity';
import type { Atom, ReadableAtom } from '#domain/protocol.ts';
import type { Reader } from '#application/ports.ts';
import { computation, type Computation, type Read } from '#infrastructure/reactivity-core.ts';
type Ref<A> = AtomRef.AtomRef<A>;
/** Read an atom inside `observe`, subscribing the component to it. */
export type Read = <A>(ref: Ref<A>) => A;
/** Create a reactive cell. Sugar for `AtomRef.make`. */
export const atom = <A,>(initial: A): Ref<A> => AtomRef.make(initial);
interface ObserveState<A> {
selector: (read: Read) => A;
deps: Map<Ref<unknown>, unknown>;
value: A;
initialized: boolean;
}
const depsEqual = (a: Map<Ref<unknown>, unknown>, b: Map<Ref<unknown>, unknown>): boolean => {
if (a.size !== b.size) {
return false;
}
for (const [ref, value] of a) {
if (!b.has(ref) || !Equal.equals(value, b.get(ref))) {
return false;
}
}
return true;
};
/**
* Run the selector, tracking which atoms it reads. Returns a *stable* reference
* when the tracked atoms and their values are unchanged, so it is safe to call
* from `getSnapshot` without provoking a render loop.
*/
const compute = <A,>(state: ObserveState<A>): A => {
const nextDeps = new Map<Ref<unknown>, unknown>();
const read: Read = (ref) => {
const value = ref.value;
nextDeps.set(ref as Ref<unknown>, value);
return value;
};
const next = state.selector(read);
if (state.initialized && depsEqual(state.deps, nextDeps)) {
state.deps = nextDeps;
return state.value;
}
state.deps = nextDeps;
state.value = next;
state.initialized = true;
return next;
};
/**
* Subscribe to a derived view over one or more atoms. Re-renders precisely when
* a read atom changes.
* Subscribe a component to an inline derived view over one or more atoms. Re-renders
* precisely when a read atom changes. This is the escape hatch for an ad-hoc reactive
* expression in a component; prefer reading a single atom with {@link useAtomValue}
* (or `yield*` in a REC), and deriving shared values with `atom.derive` / `derive` in
* a service. `<Observe>` is the same thing, inline in JSX.
*

@@ -79,36 +32,14 @@ * ```tsx

export const observe = <A,>(selector: (read: Read) => A): A => {
const stateRef = useRef<ObserveState<A> | null>(null);
if (stateRef.current === null) {
stateRef.current = { selector, deps: new Map(), value: undefined as A, initialized: false };
const selectorRef = useRef(selector);
selectorRef.current = selector;
const compRef = useRef<Computation<A> | null>(null);
if (compRef.current === null) {
compRef.current = computation((read) => selectorRef.current(read));
}
stateRef.current.selector = selector;
const subscribe = useCallback((onStoreChange: () => void) => {
const state = stateRef.current;
if (state === null) {
return () => {};
}
let unsubscribes: Array<() => void> = [];
const resubscribe = (): void => {
for (const unsub of unsubscribes) {
unsub();
}
unsubscribes = [...state.deps.keys()].map((ref) => ref.subscribe(handleChange));
};
function handleChange(): void {
compute(state as ObserveState<A>);
resubscribe();
onStoreChange();
}
compute(state);
resubscribe();
return () => {
for (const unsub of unsubscribes) {
unsub();
}
};
}, []);
const getSnapshot = useCallback(() => compute(stateRef.current as ObserveState<A>), []);
const comp = compRef.current;
const subscribe = useCallback(
(onStoreChange: () => void) => comp.subscribe(onStoreChange),
[comp],
);
const getSnapshot = useCallback(() => comp.get(), [comp]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);

@@ -118,25 +49,30 @@ };

/** Read a single atom's value, subscribing the component to it. */
export const useAtomValue = <A,>(ref: Ref<A>): A => {
const subscribe = useCallback((onStoreChange: () => void) => ref.subscribe(onStoreChange), [ref]);
const getSnapshot = useCallback(() => ref.value, [ref]);
export const useAtomValue = <A,>(atomRead: ReadableAtom<A>): A => {
const subscribe = useCallback(
(onStoreChange: () => void) => atomRead.subscribe(onStoreChange),
[atomRead],
);
const getSnapshot = useCallback(() => atomRead.value, [atomRead]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
/** A stable setter for an atom, supporting both values and updater functions. */
export const useAtomSet = <A,>(ref: Ref<A>): ((value: A | ((prev: A) => A)) => void) =>
/** A stable setter for a writable atom, supporting both values and updater functions. */
export const useAtomSet = <A,>(atomWrite: Atom<A>): ((value: A | ((prev: A) => A)) => void) =>
useCallback(
(value) => {
if (typeof value === 'function') {
ref.update(value as (prev: A) => A);
atomWrite.update(value as (prev: A) => A);
} else {
ref.set(value);
atomWrite.set(value);
}
},
[ref],
[atomWrite],
);
/** Read and write a single atom — the `useState` shape, backed by Effect. */
export const useAtom = <A,>(ref: Ref<A>): readonly [A, (value: A | ((prev: A) => A)) => void] => [
useAtomValue(ref),
useAtomSet(ref),
export const useAtom = <A,>(
atomWrite: Atom<A>,
): readonly [A, (value: A | ((prev: A) => A)) => void] => [
useAtomValue(atomWrite),
useAtomSet(atomWrite),
];

@@ -148,4 +84,28 @@

/** The render-prop form of {@link observe}. */
/** The render-prop form of {@link observe}, for inline reactive values in JSX. */
export const Observe = <A extends ReactNode>({ children }: ObserveProps<A>): ReactNode =>
observe(children);
/**
* The reader the interpreter uses to resolve a yielded atom: read its current
* value and subscribe this render to it (React's `useSyncExternalStore`). Called
* once per atom read during the render pass, in the body's stable order.
*/
export const atomReader: Reader = {
// `atom.subscribe` is a stable reference on the atom, so pass it directly —
// React must not see a new `subscribe` each render or it re-subscribes every
// time (and, for an object-valued derived atom, could churn).
read: (atomRead) =>
useSyncExternalStore(atomRead.subscribe, getValue(atomRead), getValue(atomRead)),
};
const snapshots = new WeakMap<ReadableAtom<unknown>, () => unknown>();
/** A per-atom stable `getSnapshot` (`() => atom.value`), memoised by atom identity. */
const getValue = <A,>(atomRead: ReadableAtom<A>): (() => A) => {
let snapshot = snapshots.get(atomRead) as (() => A) | undefined;
if (snapshot === undefined) {
snapshot = () => atomRead.value;
snapshots.set(atomRead, snapshot);
}
return snapshot;
};

@@ -38,2 +38,3 @@ 'use client';

import type { RecHandle, RecPlacement } from '#domain/protocol.ts';
import { atomReader } from '#infrastructure/react/reactivity.tsx';
import {

@@ -129,3 +130,10 @@ makeSuspensableResolver,

handle.body(props),
{ executor, suspender, cache, suspensableResolver: resolver, placer: clientPlacer },
{
executor,
suspender,
cache,
suspensableResolver: resolver,
reader: atomReader,
placer: clientPlacer,
},
handle.catchHandlers,

@@ -132,0 +140,0 @@ );

@@ -26,2 +26,3 @@ /**

type Hook,
type ReadableAtom,
type RecBody,

@@ -60,3 +61,4 @@ type RecHandle,

| RecPlacement<ReactNode, unknown, unknown>
| Suspensable<unknown, unknown, unknown>;
| Suspensable<unknown, unknown, unknown>
| ReadableAtom<unknown>;

@@ -63,0 +65,0 @@ interface RecCore<P, E, R, S> extends RecHandle<ReactNode> {

import { ReactNode } from "react";
import * as Effect from "effect/Effect";
//#region src/domain/protocol.d.ts
/**
* The one unavoidable `any` in the framework. The heterogeneous yield protocol
* must accept an Effect of *any* error and requirement variance — there is no
* other way to express "some Effect" as a generic constraint, which is why
* Effect's own `Effect.gen` is typed exactly this way. Precise `E` and `R` are
* recovered below through conditional inference, so this never leaks to users.
*/
type AnyEffect = Effect.Effect<any, any, any>;
/** Brand identifying a lifted React hook instruction. */
declare const HookTypeId: unique symbol;
type HookTypeId = typeof HookTypeId;
/**
* A React hook result lifted into the `yield*` channel. The hook itself has
* already executed (synchronously, during render) by the time the value is
* wrapped — `hook(useState(0))` calls `useState` inline. Wrapping it only makes
* the result yieldable, so a component body reads as one uniform stream of
* `yield*`s whether the value comes from Effect or from React.
*/
interface Hook<out A> {
readonly [HookTypeId]: true;
readonly value: A;
[Symbol.iterator](): Iterator<Hook<A>, A>;
}
/**
* Lift an already-evaluated React hook result into the effract yield channel.
*
* ```ts
* const [tab, setTab] = yield* hook(useState('overview'));
* const ref = yield* hook(useRef<HTMLDivElement>(null));
* ```
*
* Because the component body runs synchronously inside React's render pass, the
* wrapped hook call obeys the Rules of Hooks: same order on every render.
*/
declare const hook: <A>(value: A) => Hook<A>;
/** Type guard: is this yielded instruction a lifted hook? */
declare const isHook: (u: unknown) => u is Hook<unknown>;
/** Brand identifying a suspensable instruction (a `suspend`/`query`). */
declare const SuspensableTypeId: unique symbol;
type SuspensableTypeId = typeof SuspensableTypeId;
/**
* The fourth kind of yieldable: a *suspensable* — a component's declared
* asynchronous dependency. Unlike a raw `yield* effect` (which the interpreter
* may still suspend on, but silently), yielding a suspensable both suspends for
* the value *and* contributes a {@link Suspends} obligation to the REC's type,
* so the loading state must be handled somewhere (`.suspense(...)`, or the
* `mount` boundary) before the tree compiles. Its `effect` carries the `E` and
* `R` channels, which bubble as a yielded effect's do — so retries, timeouts and
* cancellation are just Effect combinators on `effect`, and its failures are
* catchable with `.catch`. `key` (compared by value) drives refetch; `undefined`
* means load-once. Both {@link suspend} and {@link query} produce this.
*/
interface Suspensable<out A, out E, out R> {
readonly [SuspensableTypeId]: true;
readonly effect: Effect.Effect<A, E, R>;
readonly key: unknown;
[Symbol.iterator](): Iterator<Suspensable<A, E, R>, A>;
}
/**
* The suspensable primitive: run `effect`, suspend the render until it settles
* (through React's `use`), and return its value — declaring a loading obligation
* the type system makes you handle. Load-once: it runs a single time per
* component instance and position, deduped across every render attempt (including
* the pre-commit retries React makes when a component suspends before it first
* commits), and its in-flight fiber is interrupted when the component unmounts.
*
* It is the building block {@link query} is made of — reach for `suspend` to
* opt an effect into Suspense without query's keyed-refetch semantics, or to
* build your own async abstractions on top.
*
* ```ts
* const config = yield* suspend(loadConfig()); // load-once
* const feed = yield* suspend(load.pipe(Effect.timeout('2s'))); // policies via Effect
* ```
*/
declare const suspend: <A, E, R>(effect: Effect.Effect<A, E, R>) => Suspensable<A, E, R>;
/**
* A keyed suspensable — {@link suspend} plus refetch. It re-runs `effect` when
* `key` changes by value (leaving `key` off makes it load-once, exactly like
* `suspend`). Same guarantees otherwise: deduped across render attempts,
* interrupted on unmount, failures catchable with `.catch`.
*
* ```ts
* const user = yield* query(fetchUser(id), id); // refetch when id changes
* ```
*/
declare const query: <A, E, R>(effect: Effect.Effect<A, E, R>, key?: unknown) => Suspensable<A, E, R>;
/** Type guard: is this yielded instruction a suspensable (`suspend`/`query`)? */
declare const isSuspensable: (u: unknown) => u is Suspensable<unknown, unknown, unknown>;
/**
* The phantom marker of an *unhandled loading obligation*. A body that yields a
* {@link Suspensable} (or places a child that still carries one) has this in its
* `S` channel; `.suspense(fallback)` — or the `mount` boundary — discharges it
* back to `never`. It never exists at runtime; it exists only so the type system
* can insist a loading state is handled somewhere between component and root. The
* branded field is nominal — it makes `Suspends` distinct from `never` and from
* any ordinary type, so the discharge checks are exact. Loading is a single
* catch-all obligation (not a union like the tagged error channel): a pending
* effect has no tag, so one `.suspense` discharges the whole subtree beneath it.
*/
interface Suspends {
readonly ['@tmonier/effract/loading']: true;
}
/** Everything a component body may `yield*`: an Effect, a hook, a child placement, or a query. */
type Yieldable<A> = Effect.Effect<A, unknown, unknown> | Hook<A> | RecPlacement<A, unknown, unknown> | Suspensable<A, unknown, unknown>;
/** The generator a React Effect Component body produces. */
type RecGenerator<A> = Generator<AnyEffect | Hook<unknown> | RecPlacement<unknown, unknown, unknown> | Suspensable<unknown, unknown, unknown>, A, unknown>;
/** A component body: props in, a generator of yields ending in a rendered `A`. */
type RecBody<Props, A> = (props: Props) => RecGenerator<A>;
/**
* Runtime dispatch table for `.catch`: an error `_tag` maps to the node to
* render in that error's place. Renderer-agnostic in `A` — the domain never
* learns that a node is a React element; it only carries the mapping so both the
* client interpreter and the server driver can honour it. The typed, exhaustive
* shape users write (`CatchHandlers`) narrows to this at the `.catch` boundary.
*/
type CatchDispatch<A> = Readonly<Record<string, (error: unknown) => A>>;
/**
* A stable handle to a component's body — the identity a placement points at.
* The client keys its per-descriptor React component on this object (so a child
* keeps a stable React type across re-renders); the server drives its `body`
* directly. Deliberately renderer-agnostic: it names *what* to run, not how.
*/
interface RecHandle<A> {
readonly body: (props: any) => RecGenerator<A>;
readonly displayName: string;
/**
* Typed-error fallbacks for this REC, if it was `.catch`-wrapped: a failure
* from one of *its own* `yield*`ed effects whose `_tag` is present renders the
* mapped node instead of propagating. Absent for a plain REC.
*/
readonly catchHandlers?: CatchDispatch<A>;
/**
* The loading fallback for this REC, if it was `.suspense`-wrapped: the client
* places it in a real `<Suspense>` boundary so a yielded {@link Suspensable}'s
* pending state renders this node. Renderer-agnostic in `A`; absent otherwise.
*/
readonly suspenseFallback?: A;
}
/** Brand identifying a child-REC placement instruction. */
declare const PlacementTypeId: unique symbol;
type PlacementTypeId = typeof PlacementTypeId;
/**
* The third kind of yieldable, alongside Effects and Hooks: the placement of a
* *child* REC into a parent's tree (`yield* Child` / `yield* Child.with(p)`).
*
* A placement carries only data — the child's stable `rec` handle and its
* `props` — never a bound React element. That is what lets one `rec(...)` value
* be shared across runtimes: the client turns a placement into a real React
* child fiber, the server drives the child's body inline. The phantom `R` makes
* the child's Effect requirements flow up through `yield*` to `mount`/`serve`,
* exactly as a yielded service's requirements do.
*/
interface RecPlacement<A, R, S = never> {
readonly [PlacementTypeId]: true;
readonly rec: RecHandle<A>;
readonly props: object;
/**
* Phantom carrier for the requirements this placement contributes upward.
* Covariant (an output position) so a concrete placement — e.g. one needing
* `Stats` — widens to `RecPlacement<_, unknown>` in a body's yield union, just
* as `Effect<_, _, Stats>` widens to `AnyEffect`. Never set at runtime.
*/
readonly _requirements?: R;
/**
* Phantom carrier for the child's unhandled loading obligation `S`. Covariant,
* like `_requirements`, so a placed child that still suspends bubbles its
* {@link Suspends} up to the parent's `S` — until an ancestor `.suspense`s it.
* Never set at runtime.
*/
readonly _suspends?: S;
[Symbol.iterator](): Iterator<RecPlacement<A, R, S>, A>;
}
/**
* Construct a child placement. Single-shot iterable like {@link hook}: `yield*`
* hands the interpreter the placement instruction, and the value fed back in
* (the rendered child node) becomes the result of the `yield*`.
*/
declare const placement: <A, R, S = never>(rec: RecHandle<A>, props: object) => RecPlacement<A, R, S>;
/** Type guard: is this yielded instruction a child-REC placement? */
declare const isPlacement: (u: unknown) => u is RecPlacement<unknown, unknown, unknown>;
/**
* Distribute over the yield union and keep only its Effect members. Hooks are
* not Effects, so they drop away — leaving just what carries `E` and `R`.
*/
type EffectsOnly<Eff> = Eff extends AnyEffect ? Eff : never;
/**
* Re-express each yielded placement as an effect carrying only its child's
* requirements, so a placed child's `R` joins the parent's the same way a
* yielded service's does — one child, its whole subtree's services bubble up.
*/
type PlacementsAsEffects<Eff> = Eff extends RecPlacement<unknown, infer R, unknown> ? Effect.Effect<unknown, unknown, R> : never;
/**
* Re-express each yielded suspensable as an effect carrying its `E` and `R`, so
* a suspensable's errors and requirements join the body's exactly as a raw
* effect's do.
*/
type SuspensablesAsEffects<Eff> = Eff extends Suspensable<unknown, infer E, infer R> ? Effect.Effect<unknown, E, R> : never;
/**
* Recover the Effect requirement channel `R` from everything a body yields —
* services, effects, placed child RECs, and suspensables. A body that needs both
* `A` and `B` requires `A & B`, which is exactly the intersection TypeScript
* infers from the contravariant requirement slot.
*/
type RequirementsOf<Eff> = [EffectsOnly<Eff> | PlacementsAsEffects<Eff> | SuspensablesAsEffects<Eff>] extends [Effect.Effect<unknown, unknown, infer R>] ? R : never;
/** Recover the Effect error channel `E` (a union — any yielded effect or suspensable may fail). */
type ErrorsOf<Eff> = [EffectsOnly<Eff> | SuspensablesAsEffects<Eff>] extends [Effect.Effect<unknown, infer E, unknown>] ? E : never;
/**
* Recover the loading obligation `S`. A yielded {@link Suspensable} contributes
* {@link Suspends}; a placed child contributes whatever `S` it still carries.
* The union is `Suspends` if *anything* below is unhandled, and `never` once it
* all is — the exact condition `mount` (and `.suspense`) check.
*/
type SuspendsOf<Eff> = Eff extends Suspensable<unknown, unknown, unknown> ? Suspends : Eff extends RecPlacement<unknown, unknown, infer S> ? S : never;
//#endregion
//#region src/infrastructure/rec-core.d.ts
/** Brand identifying a React Effect Component. */
declare const RecTypeId: unique symbol;
type RecTypeId = typeof RecTypeId;
/** A tagged error — the shape `.catch` dispatches on (`Data.TaggedError`, …). */
type Tagged = {
readonly _tag: string;
};
/** The non-tagged remainder of an error channel — errors `.catch` cannot name. */
type UntaggedErrors<E> = Exclude<E, Tagged>;
/**
* The exhaustive handler map for a REC's error channel: one fallback per error
* `_tag`, each receiving exactly that error. Omitting a tag the body can fail
* with is a compile error (a missing property); an unknown tag is rejected as an
* excess property. A REC that cannot fail with a tagged error takes `{}`.
*/
type CatchHandlers<E, A = ReactNode> = { readonly [Tag in Extract<E, Tagged>['_tag']]: (error: Extract<E, {
readonly _tag: Tag;
}>) => A };
/** Any yieldable a REC body may produce — the constraint `rec` infers `Eff` against. */
type AnyYield = AnyEffect | Hook<unknown> | RecPlacement<ReactNode, unknown, unknown> | Suspensable<unknown, unknown, unknown>;
interface RecCore<P, E, R, S> extends RecHandle<ReactNode> {
readonly [RecTypeId]: true;
/** Place this REC with props: `yield* Child.with({ ... })`. */
with(props: P): RecPlacement<ReactNode, R, S>;
/**
* Render a typed fallback for each error this REC's body can fail with. The
* handler map is exhaustive over the error channel `E` and checked at compile
* time, so you cannot forget a tag or invent one. Each failure — synchronous
* or async — renders its mapped node in place of the component; defects and
* any non-tagged errors are left to the nearest React error boundary.
*
* ```tsx
* const Profile = rec(function* () {
* const user = yield* query(fetchUser(id), id); // E = NotFound | Unauthorized
* return <Card user={user} />;
* }).catch({
* NotFound: () => <Empty />,
* Unauthorized: () => <Login />,
* });
* ```
*
* Returns a REC whose error channel keeps only the non-tagged remainder
* (usually `never`); the loading obligation `S` is untouched. It catches *this*
* REC's own `yield*`ed failures; a child REC handles its own (wrap it too).
*/
catch(handlers: CatchHandlers<E>): REC<P, UntaggedErrors<E>, R, S>;
/**
* Handle this REC's loading state. A REC that `yield*`s a `suspend`/`query`
* carries a loading obligation `S`; `.suspense(fallback)` discharges it by
* placing the REC in a real `<Suspense>` boundary, so while it is pending the
* `fallback` renders in its place.
*
* ```tsx
* const Page = Profile.suspense(<Skeleton />); // Page: REC<…, never> — obligation met
* ```
*
* Returns a REC with `S` back to `never`. One `.suspense` discharges the whole
* subtree beneath it (React Suspense catches every descendant that suspends),
* so a single boundary — at any ancestor, or at `mount` via `{ loading }` —
* satisfies the obligation the type system otherwise bubbles all the way up.
*/
suspense(fallback: ReactNode): REC<P, E, R, never>;
}
interface RecBareYield<R, S> {
/** Place this REC without props: `yield* Child`. */
[Symbol.iterator](): Iterator<RecPlacement<ReactNode, R, S>, ReactNode>;
}
/**
* A React Effect Component. Yieldable (so `R` and `S` propagate), never a JSX
* element type — `<Rec />` is a compile error by design. Props-free RECs can be
* yielded directly (`yield* Child`); RECs with props use `yield* Child.with(p)`.
* `E` carries the tagged failures its body can raise (`.catch` discharges them);
* `S` carries an unhandled loading obligation (`.suspense` discharges it).
*/
type REC<P, E, R, S> = RecCore<P, E, R, S> & ([Record<never, never>] extends [P] ? RecBareYield<R, S> : unknown);
/**
* Define a React Effect Component. The body is a generator that may `yield*`
* Effect services and effects, `yield* hook(...)` for React hooks, `yield*
* suspend(...)` / `query(...)` for async data, and `yield* Child` / `yield* Child.with(props)` to
* place other RECs.
*
* The returned descriptor is server-safe: the same `mount(...)` renders a
* hook-free body on the server, and the very same value mounts on the client.
* Only a body that itself imports client-only APIs (React hooks) makes its
* *module* client-only — the `rec` wrapper never does.
*/
declare function rec<Eff extends AnyYield, A extends ReactNode>(body: () => Generator<Eff, A, never>): REC<Record<never, never>, ErrorsOf<Eff>, RequirementsOf<Eff>, SuspendsOf<Eff>>;
declare function rec<Eff extends AnyYield, A extends ReactNode, Props extends object>(body: (props: Props) => Generator<Eff, A, never>): REC<Props, ErrorsOf<Eff>, RequirementsOf<Eff>, SuspendsOf<Eff>>;
/** A type error naming the services a runtime is missing for a REC's tree. */
type MissingServices<Missing> = readonly ['effract: runtime is missing', Missing];
/** A type error demanding a loading fallback for a tree that can still suspend. */
type LoadingNotHandled = readonly ['effract: loading not handled — add .suspense(fallback), or mount(layer, root, { loading })'];
/**
* A no-service tree's requirement infers as `unknown` (Effect's requirement
* channel is contravariant, so `never` widens). Normalise that to `never` so a
* runtime-free tree mounts/serves under any layer.
*/
type Effective<R> = [unknown] extends [R] ? never : R;
//#endregion
export { suspend as A, Yieldable as C, isSuspensable as D, isPlacement as E, placement as O, SuspensableTypeId as S, isHook as T, RecPlacement as _, REC as a, SuspendsOf as b, AnyEffect as c, Hook as d, HookTypeId as f, RecHandle as g, RecGenerator as h, MissingServices as i, query as k, CatchDispatch as l, RecBody as m, Effective as n, RecTypeId as o, PlacementTypeId as p, LoadingNotHandled as r, rec as s, CatchHandlers as t, ErrorsOf as u, RequirementsOf as v, hook as w, Suspensable as x, Suspends as y };
//#region src/domain/protocol.ts
/** Brand identifying a lifted React hook instruction. */
const HookTypeId = Symbol.for("@tmonier/effract/Hook");
/**
* Lift an already-evaluated React hook result into the effract yield channel.
*
* ```ts
* const [tab, setTab] = yield* hook(useState('overview'));
* const ref = yield* hook(useRef<HTMLDivElement>(null));
* ```
*
* Because the component body runs synchronously inside React's render pass, the
* wrapped hook call obeys the Rules of Hooks: same order on every render.
*/
const hook = (value) => {
const self = {
[HookTypeId]: true,
value,
[Symbol.iterator]() {
let yielded = false;
return { next(sent) {
if (yielded) return {
done: true,
value: sent
};
yielded = true;
return {
done: false,
value: self
};
} };
}
};
return self;
};
/** Type guard: is this yielded instruction a lifted hook? */
const isHook = (u) => typeof u === "object" && u !== null && HookTypeId in u;
/** Brand identifying a suspensable instruction (a `suspend`/`query`). */
const SuspensableTypeId = Symbol.for("@tmonier/effract/Suspensable");
const makeSuspensable = (effect, key) => {
const self = {
[SuspensableTypeId]: true,
effect,
key,
[Symbol.iterator]() {
let yielded = false;
return { next(sent) {
if (yielded) return {
done: true,
value: sent
};
yielded = true;
return {
done: false,
value: self
};
} };
}
};
return self;
};
/**
* The suspensable primitive: run `effect`, suspend the render until it settles
* (through React's `use`), and return its value — declaring a loading obligation
* the type system makes you handle. Load-once: it runs a single time per
* component instance and position, deduped across every render attempt (including
* the pre-commit retries React makes when a component suspends before it first
* commits), and its in-flight fiber is interrupted when the component unmounts.
*
* It is the building block {@link query} is made of — reach for `suspend` to
* opt an effect into Suspense without query's keyed-refetch semantics, or to
* build your own async abstractions on top.
*
* ```ts
* const config = yield* suspend(loadConfig()); // load-once
* const feed = yield* suspend(load.pipe(Effect.timeout('2s'))); // policies via Effect
* ```
*/
const suspend = (effect) => makeSuspensable(effect, void 0);
/**
* A keyed suspensable — {@link suspend} plus refetch. It re-runs `effect` when
* `key` changes by value (leaving `key` off makes it load-once, exactly like
* `suspend`). Same guarantees otherwise: deduped across render attempts,
* interrupted on unmount, failures catchable with `.catch`.
*
* ```ts
* const user = yield* query(fetchUser(id), id); // refetch when id changes
* ```
*/
const query = (effect, key) => makeSuspensable(effect, key);
/** Type guard: is this yielded instruction a suspensable (`suspend`/`query`)? */
const isSuspensable = (u) => typeof u === "object" && u !== null && SuspensableTypeId in u;
/** Brand identifying a child-REC placement instruction. */
const PlacementTypeId = Symbol.for("@tmonier/effract/Placement");
/**
* Construct a child placement. Single-shot iterable like {@link hook}: `yield*`
* hands the interpreter the placement instruction, and the value fed back in
* (the rendered child node) becomes the result of the `yield*`.
*/
const placement = (rec, props) => {
const self = {
[PlacementTypeId]: true,
rec,
props,
[Symbol.iterator]() {
let yielded = false;
return { next(sent) {
if (yielded) return {
done: true,
value: sent
};
yielded = true;
return {
done: false,
value: self
};
} };
}
};
return self;
};
/** Type guard: is this yielded instruction a child-REC placement? */
const isPlacement = (u) => typeof u === "object" && u !== null && PlacementTypeId in u;
//#endregion
//#region src/infrastructure/rec-core.tsx
/** Brand identifying a React Effect Component. */
const RecTypeId = Symbol.for("@tmonier/effract/Rec");
const makeRec = (body, name, catchHandlers, suspenseFallback) => {
const rec = {
[RecTypeId]: true,
body,
displayName: name,
...catchHandlers === void 0 ? {} : { catchHandlers },
...suspenseFallback === void 0 ? {} : { suspenseFallback },
with(props) {
return placement(rec, props);
},
catch(handlers) {
return makeRec(body, name, handlers, suspenseFallback);
},
suspense(fallback) {
return makeRec(body, name, catchHandlers, fallback);
},
[Symbol.iterator]() {
return placement(rec, {})[Symbol.iterator]();
}
};
Object.defineProperty(rec, "name", { value: name });
return rec;
};
function rec(body) {
return makeRec(body, body.name || "EffractComponent");
}
//#endregion
export { SuspensableTypeId as a, isPlacement as c, query as d, suspend as f, PlacementTypeId as i, isSuspensable as l, rec as n, hook as o, HookTypeId as r, isHook as s, RecTypeId as t, placement as u };