/** * The render channel (ADR 0007), locked at the type level: label literals, * the `Volatile` obligation and its two discharges (`defer` at the read, * `.hot()` on the REC), `V` bubbling through placements, `RendersOn` * introspection, and how every derive form inherits `V`/`L` from its sources. * The runtime block is minimal by design — the suite's real assertions are the * tsgo-checked blocks at the bottom (see `rec.test.tsx` for the convention). */ import type { ReactNode } from 'react'; import { describe, expect, it } from 'vitest'; import * as Effect from 'effect/Effect'; import * as Equal from 'effect/Equal'; import * as Hash from 'effect/Hash'; import * as Layer from 'effect/Layer'; import * as Schema from 'effect/Schema'; import { atom, atomFamily, defer, derive, mount, rec, type AsyncDerived, type Atom, type MemoRequiresComparableProps, type ReadableAtom, type RendersOn, type Volatile, } from '../../index.client.ts'; import { renderToStream } from '../../index.ssr.ts'; describe('defer (ADR 0007)', () => { it('memoises the wrapper per source and reads through to the live value', () => { // The cast stands in for `atom.fromStream` (which needs an Effect scope); // the runtime cell is an ordinary atom — `Volatile` is phantom. const source = atom('rc-ticker', 1) as unknown as Atom; const deferred = defer(source); expect(defer(source)).toBe(deferred); // stable identity → stable hook order expect(deferred.value).toBe(1); let notified = 0; const unsubscribe = deferred.subscribe(() => { notified += 1; }); source.set(2); expect(deferred.value).toBe(2); // reads through to the source expect(notified).toBe(1); unsubscribe(); }); }); // --- type-level guarantees (checked by tsgo, not run) --- { /** Two-way extends: `true` iff `X` and `Y` are exactly the same type (never-safe). */ type Same = [X] extends [Y] ? ([Y] extends [X] ? true : never) : never; type LabelOf = T extends ReadableAtom ? L : never; type VolOf = T extends ReadableAtom ? V : never; // 1. Labels — `atom(label, initial)` captures the label as a literal type; a // widened annotation surfaces the wide `string`, never `never`. const count = atom('count', 0); const _countLabel: Same, 'count'> = true; void _countLabel; const _wideLabel: Same>, string> = true; void _wideLabel; // 2. Volatility — a volatile read is a mount-checked obligation. The cast // stands in for a born-volatile source (`atom.fromStream`, which returns // `Atom`); the generator bodies below never run. const price = atom('price', 0) as unknown as Atom; const Ticker = rec(function* () { const p = yield* price; return {p}; }); // ✗ an undischarged volatile subscription refuses to mount: // @ts-expect-error effract: volatile subscription not handled const _volatileMount: ReactNode = mount(Layer.empty, Ticker); void _volatileMount; // ✗ `{ loading }` discharges only S — a scheduling decision is not a fallback: // @ts-expect-error effract: volatile subscription not handled const _loadingNoV: ReactNode = mount(Layer.empty, Ticker, { loading: }); void _loadingNoV; // ✓ `.hot()` is the deliberate acknowledgment — V back to never: void mount(Layer.empty, Ticker.hot()); // ✓ `defer(atom)` discharges at the read — and keeps the label: const Lagged = rec(function* () { const p = yield* defer(price); return {p}; }); void mount(Layer.empty, Lagged); const _laggedRenders: Same, 'price'> = true; void _laggedRenders; // `renderToStream` (the /ssr half) mirrors the gate. Wrapped in a function // that is never called, so nothing actually streams at import time. const _ssrGate = () => { // ✗ an undischarged V is refused on the server entry too: // @ts-expect-error effract: volatile subscription not handled void renderToStream(Layer.empty, Ticker); // ✓ acknowledged heat streams: void renderToStream(Layer.empty, Ticker.hot()); }; void _ssrGate; // 3. V bubbles through placements exactly like S — a hot child wrapped one // component deep cannot silently escape `mount`. const Parent = rec(function* () { return
{yield* Ticker}
; }); // ✗ placing an unacknowledged hot child keeps the obligation on the parent: // @ts-expect-error effract: volatile subscription not handled const _bubbled: ReactNode = mount(Layer.empty, Parent); void _bubbled; // ✓ placing the acknowledged child discharges it before it bubbles: const Acknowledged = Ticker.hot(); const CoolParent = rec(function* () { return
{yield* Acknowledged}
; }); void mount(Layer.empty, CoolParent); // 4. RendersOn — the exact union of the labels a body subscribes to. const items = atom('items', [1, 2]); const Board = rec(function* () { const c = yield* count; const list = yield* items; return ( {c}:{list.length} ); }); const _boardRenders: Same, 'count' | 'items'> = true; void _boardRenders; // No reactive yields → never (an Effect yield is not a subscription): const Static = rec(function* () { const n = yield* Effect.succeed(1); return {n}; }); const _staticRenders: Same, never> = true; void _staticRenders; // A child's labels stay the child's — placements do NOT bubble labels // (a child re-renders on its own subscriptions without re-rendering its // parent), so `Shell` renders on 'outer' alone, excluding the leaf's 'inner': const inner = atom('inner', 0); const Leaf = rec(function* () { const v = yield* inner; return {v}; }); const outer = atom('outer', 0); const Shell = rec(function* () { const o = yield* outer; return (
{o} {yield* Leaf}
); }); const _shellRenders: Same, 'outer'> = true; void _shellRenders; // 5. Derives inherit V and L from their sources — with zero annotation. // One volatile source is enough to keep the derived value volatile. const total = derive([count, price], ([c, p]) => c + p); const _totalV: Same, Volatile> = true; void _totalV; const _totalL: Same, 'count' | 'price'> = true; void _totalL; const TotalView = rec(function* () { const t = yield* total; return {t}; }); // ✗ the inherited volatility is still a mount obligation: // @ts-expect-error effract: volatile subscription not handled const _derivedVolatile: ReactNode = mount(Layer.empty, TotalView); void _derivedVolatile; void mount(Layer.empty, TotalView.hot()); const _totalRenders: Same, 'count' | 'price'> = true; void _totalRenders; // The single-source `.derive` method preserves both channels… const doubled = price.derive((p) => p * 2); const _doubled: Same> = true; void _doubled; // …`derive.writable` inherits exactly like the read-only form… const clamped = derive.writable( [count, price], ([c, p]) => Math.min(c, p), () => {}, ); const _clamped: Same> = true; void _clamped; // …and `defer` strips V while keeping L. const cooled = defer(price); const _cooledV: Same, never> = true; void _cooledV; const _cooledL: Same, 'price'> = true; void _cooledL; // `derive.effect` carries the effect's E/R (and the loading obligation S) as // before, AND its `AsyncDerived` V/L params inherited from the sources: type Nope = { readonly _tag: 'Nope' }; type Db = { readonly db: true }; const priced = derive.effect( [count, price], () => null as unknown as Effect.Effect, ); const _priced: Same< typeof priced, AsyncDerived > = true; void _priced; const sum = derive.effect([count, price], ([c, p]) => Effect.succeed(c + p)); const Sum = rec(function* () { const v = yield* sum; return {v}; }); // ✗ `.hot()` alone leaves the loading obligation (S rides along as before): // @ts-expect-error effract: loading not handled const _sumNoSuspense: ReactNode = mount(Layer.empty, Sum.hot()); void _sumNoSuspense; // ✗ `.suspense` alone leaves the inherited volatility: // @ts-expect-error effract: volatile subscription not handled const _sumNoHot: ReactNode = mount(Layer.empty, Sum.suspense()); void _sumNoHot; // ✓ both channels discharged: void mount(Layer.empty, Sum.suspense().hot()); const _sumRenders: Same, 'count' | 'price'> = true; void _sumRenders; // 6. `atom.serializable` — the wire key IS the label. const prefs = atom.serializable('prefs', 'dark', Schema.String); const _prefsLabel: Same, 'prefs'> = true; void _prefsLabel; // 7. `atomFamily` — family-labeled; member atoms are typed exactly as before // (a member made with a runtime-string label stays wide). const rows = atomFamily('rows', (id: string) => atom(id, 0)); const row = rows('a'); const _row: Same> = true; void _row; // 8. `.memo()` — explicit, type-guided memoization (decision 4). The gate: // every prop must be value-comparable (a primitive, or an `Equal.Equal` // implementor). A function-valued prop fails it, and the result is the // branded error naming the offending key — unusable as a REC. const WithFn = rec(function* (props: { label: string; onPing: () => void }) { void props.onPing; return {props.label}; }); const BadMemo = WithFn.memo(); const _badBrand: Same> = true; void _badBrand; // ✗ the branded result cannot be placed with props… // @ts-expect-error effract: .memo() requires value-comparable props — 'onPing' void BadMemo.with({ label: 'x', onPing: () => {} }); // …and cannot mount: // @ts-expect-error effract: .memo() requires value-comparable props — 'onPing' const _badMount: ReactNode = mount(Layer.empty, BadMemo); void _badMount; // ✓ all-primitive props pass the gate: const Prim = rec(function* (props: { id: string; count: number }) { return ( {props.id}:{props.count} ); }); const MemoPrim = Prim.memo(); const PrimParent = rec(function* () { return
{yield* MemoPrim.with({ id: 'a', count: 1 })}
; }); void mount(Layer.empty, PrimParent); // ✓ an `Equal.Equal`-implementing value class passes it for structured data: class Pair implements Equal.Equal { constructor( readonly a: number, readonly b: number, ) {} [Equal.symbol](that: Equal.Equal): boolean { return that instanceof Pair && this.a === that.a && this.b === that.b; } [Hash.symbol](): number { return Hash.string(`${this.a},${this.b}`); } } const WithPair = rec(function* (props: { pair: Pair }) { return {props.pair.a}; }); const MemoPair = WithPair.memo(); void MemoPair.with({ pair: new Pair(1, 2) }); // `.memo()` preserves every channel. V still gates mount (a props-free REC // passes the gate trivially — nothing to compare)… const MemoTicker = Ticker.memo(); // @ts-expect-error effract: volatile subscription not handled const _memoVolatile: ReactNode = mount(Layer.empty, MemoTicker); void _memoVolatile; void mount(Layer.empty, MemoTicker.hot()); // …S rides along (a memoized volatile-acknowledged tree still owes loading)… // @ts-expect-error effract: loading not handled const _memoLoading: ReactNode = mount(Layer.empty, Sum.hot().memo()); void _memoLoading; void mount( Layer.empty, Sum.suspense() .hot() .memo(), ); // …and RendersOn survives `.memo()`: const _memoRenders: Same, 'price'> = true; void _memoRenders; }