# @azmr/core

> Reactive engine — signals, effects, and computed values.

Source: https://platform.azmara.io/docs/packages/core

## Installation

```bash
pnpm add @azmr/core
```

## Signal

A reactive value container. Any effect that reads `.get()` while active will re-run when the value changes.

```typescript
import { Signal } from "@azmr/core";

const count = new Signal(0);
count.set(1);
console.log(count.get()); // 1
console.log(count.peek()); // 1 — reads without subscribing
```

### Custom equality

`set()` uses `Object.is` by default, so setting an object/array to a new reference always notifies subscribers, even if its shape is unchanged. Pass an `equals` option to compare by shape instead:

```typescript
const shallowEqual = (a: { x: number }, b: { x: number }) => a.x === b.x;
const point = new Signal({ x: 1 }, { equals: shallowEqual });

point.set({ x: 1 }); // no-op — equals() says these are the same
```

## effect

Runs a function immediately and re-runs whenever any Signal read inside it changes. Returns a disposer that detaches the effect from every signal it read — safe to call more than once, and safe to call from inside another effect during the same flush.

```typescript
import { Signal, effect } from "@azmr/core";

const name = new Signal("Aroha");

const dispose = effect(() => {
  console.log(`Hello, ${name.get()}`);
});
// → Hello, Aroha

name.set("Tane");
// → Hello, Tane

dispose();
name.set("Mere"); // no longer logged — the effect is detached
```

> **Note**
>
> A conditional read (`cond.get() ? a.get() : b.get()`) re-evaluates its dependencies on every
> re-run, so switching branches drops the subscription to the signal in the untaken branch.

### Cleanup

The callback may return a cleanup function. It runs right before the next re-run (after stale
dependencies are dropped, before the callback runs again) and on dispose — the same shape as
React's `useEffect`.

```typescript
effect(() => {
  const id = setInterval(() => console.log(count.get()), 1000);
  return () => clearInterval(id);
});
```

## computed

A read-only Signal whose value is derived from other Signals. The returned Signal carries a `dispose()` for tearing down the derived chain.

```typescript
import { Signal, computed } from "@azmr/core";

const price = new Signal(100);
const gst = computed(() => price.get() * 0.15);

console.log(gst.get()); // 15
price.set(200);
console.log(gst.get()); // 30

gst.dispose();
price.set(9999);
console.log(gst.peek()); // 30 — frozen at its last computed value
```

### Lazy evaluation

`fn` doesn't run at creation. While the computed is unobserved — nothing reading it via `effect()`, another `computed()`, or `subscribe()` — an upstream change only marks it stale; the actual recomputation is deferred to the next `.get()`/`.peek()`, and always uses the latest values, not whatever was current when the signal changed. Once something depends on it, it switches to recomputing eagerly on every upstream change, matching the old always-eager behaviour, so an observing effect still only re-runs when the derived value itself actually changes.

```typescript
const price = new Signal(100);
const gst = computed(() => price.get() * 0.15); // fn hasn't run yet

price.set(200); // gst is unobserved — just marked stale, not recomputed
price.set(300);

gst.get(); // 45 — computes once here, from the latest value
```

> **Note**
>
> Behaviour change: a throwing `fn` used to throw synchronously from `computed(fn)` itself. It now
> throws from the first `.get()`/`.peek()` instead, since that's when `fn` actually runs.

## subscribe

Listen to a Signal from outside the reactive system — useful for bridging to React, Vue, or any non-reactive code.

```typescript
import { Signal } from "@azmr/core";

const price = new Signal(100);

const unsubscribe = price.subscribe((value) => {
  console.log(`price changed to ${value}`);
});

price.set(200); // → price changed to 200

unsubscribe(); // stop listening
```

## batch

Coalesces every `set()` call made inside a callback into a single effect flush, instead of one flush per `set()`. Values update synchronously as usual — `.get()`/`.peek()` inside the callback always see the latest write; only the effect flush is deferred. Nests correctly: an inner `batch()` completing does not trigger a flush while an outer one is still open.

```typescript
import { Signal, batch, effect } from "@azmr/core";

const first = new Signal("Aroha");
const last = new Signal("Ngata");

effect(() => console.log(`${first.get()} ${last.get()}`));
// → Aroha Ngata

batch(() => {
  first.set("Tane");
  last.set("Mahuta");
});
// → Tane Mahuta   (logged once, not twice)
```

## untrack

Runs a callback with dependency tracking suspended — any `.get()` calls made inside it (including ones buried in code you call into, not just a direct call at the top level) don't register a dependency on the currently-running effect. Unlike `.peek()`, which only works where you control the call site, `untrack()` composes through indirection — it works even when the callback calls third-party or generic code that uses `.get()` internally. A no-op outside an effect. An effect created inside the callback still tracks its own reads normally.

```typescript
import { Signal, effect, untrack } from "@azmr/core";

const count = new Signal(0);
const debugFlag = new Signal(false);

effect(() => {
  // Reacts to count, but reading debugFlag here should never itself
  // trigger a re-run — it's just checked, not depended on.
  if (untrack(() => debugFlag.get())) console.log(`count: ${count.get()}`);
});
```

## onError

Registers a handler for errors thrown by an `effect()` re-run or a `subscribe()` callback during a flush. Without this, one bad subscriber's exception propagates out of an unrelated caller's `set()` and aborts every other subscriber still pending in that flush; with a handler registered, the error is routed here instead and the flush continues. Returns a function that restores whichever handler was active before the call. Doesn't apply to `effect(fn)`'s first, synchronous run, or to a direct `fn()` call inside `batch()`/`untrack()` — those still throw normally to the caller, who is right there to catch them.

```typescript
import { Signal, effect, onError } from "@azmr/core";

const restore = onError((error) => {
  console.error("effect failed:", error);
});

const count = new Signal(0);
effect(() => {
  if (count.get() > 10) throw new Error("too high");
});

restore(); // back to the previous handler (none by default)
```

> **Note**
>
> If no handler is registered, the error is rethrown asynchronously (via a resolved Promise) so
> it isn't silently swallowed, without blocking the flush that surfaced it.

## Safety

The scheduler uses a generation counter to prevent effects from running more than once per flush cycle. An effect that reads and writes the same signal will run at most twice — the re-queue is deduplicated, preventing unbounded loops.

```typescript
const s = new Signal(0);

// Safe — deduplicated by the scheduler, never runs unboundedly
effect(() => {
  s.get();
  s.set(s.peek() + 1);
});
```

## API Reference

### `Signal<T>`

Constructor: `new Signal(initialValue, options?)`. `options.equals` replaces the default `Object.is` check `set()` uses to decide whether a write is a no-op.

| Method          | Returns      | Description                                       |
| --------------- | ------------ | ------------------------------------------------- |
| `get()`         | `T`          | Read value and subscribe current effect           |
| `set(value)`    | `void`       | Update value and notify subscribers               |
| `peek()`        | `T`          | Read value without subscribing                    |
| `subscribe(fn)` | `() => void` | Register a callback, returns unsubscribe function |

### Functions

| Function           | Description                                                                                                |
| ------------------ | ---------------------------------------------------------------------------------------------------------- |
| `effect(fn)`       | Run `fn` reactively, returns a disposer that fully detaches it                                             |
| `computed(fn)`     | Create a derived read-only Signal, computed lazily; return value has a `dispose()`                         |
| `batch(fn)`        | Coalesce `set()` calls inside `fn` into one flush; returns `fn`'s return value                             |
| `untrack(fn)`      | Run `fn` with dependency tracking suspended; returns `fn`'s return value                                   |
| `onError(handler)` | Register a handler for errors thrown during a flush; returns a function that restores the previous handler |
