alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
149 lines (134 loc) • 4.63 kB
text/typescript
import { KIND } from "../constants/KIND.ts";
import { AlephaError } from "../errors/AlephaError.ts";
import type { Atom, AtomStatic } from "./$atom.ts";
/**
* Define a derived, read-only value computed from one or more atoms
* (or other computed values).
*
* Dependencies are declared statically, which keeps the data flow explicit
* and lets subscribers know exactly which mutations invalidate the value.
*
* Computed values are never stored, serialized, hydrated, or persisted —
* they are recomputed from their dependencies on every read, so they are
* always correct inside request-scoped (fork) state on the server.
*
* ```ts
* const cartTotal = $computed({
* name: "cartTotal",
* deps: [cartAtom],
* get: (cart) => cart.items.reduce((sum, it) => sum + it.price, 0),
* });
*
* alepha.store.get(cartTotal); // number
* ```
*
* ### Footgun: a `serverOnly` dependency breaks hydration
*
* A computed whose deps include a `serverOnly` atom will NOT agree across the
* SSR boundary. `serverOnly` keeps the atom out of the hydration payload, so
* the server derives the value from the atom's real value while the browser
* re-derives it from the atom's *default* — React then hydrates a DOM that
* does not match the markup it received (a hydration mismatch, and a
* silently wrong value afterwards).
*
* Either keep the computed server-side only (read it via `alepha.store.get`,
* never through `useComputed`), or derive it from atoms that ship to the
* browser. Note this cuts the other way too: if the value is safe to send to
* the browser, the dependency should not have been `serverOnly` to begin
* with.
*/
export const $computed = <
const D extends ReadonlyArray<AnyDep>,
R,
N extends string = string,
>(
options: ComputedOptions<D, R, N>,
): Computed<R, N> => {
return new Computed<R, N>(options as ComputedOptions<any, R, N>);
};
export type AnyDep = Atom<any, any> | Computed<any, any>;
export type DepValues<D extends ReadonlyArray<AnyDep>> = {
[K in keyof D]: D[K] extends Atom<infer T, any>
? AtomStatic<T>
: D[K] extends Computed<infer R2, any>
? R2
: never;
};
export interface ComputedOptions<
D extends ReadonlyArray<AnyDep>,
R,
N extends string = string,
> {
name: N;
deps: D;
get: (...values: DepValues<D>) => R;
description?: string;
}
export class Computed<R = unknown, N extends string = string> {
public readonly options: ComputedOptions<any, R, N>;
constructor(options: ComputedOptions<any, R, N>) {
this.options = options;
}
public get key(): N {
return this.options.name;
}
/**
* Reentrancy guard for {@link keys}. Deps are declared as a `readonly`
* array at construction time, so a cycle can't be built through the
* public API — but the array itself isn't frozen, so nothing stops a
* caller from reaching in and creating one anyway. Without this guard,
* a cycle would recurse until the JS engine throws a native
* `RangeError`, which violates the repo's "never throw a bare `Error`"
* convention.
*/
protected resolvingKeys = false;
/**
* Reentrancy guard for {@link compute}, same rationale as
* {@link resolvingKeys}.
*/
protected computing = false;
/**
* Transitive atom keys this computed value depends on. Subscribers
* (useComputed, StateManager.watch) use this to know which `state:mutate`
* events invalidate the value.
*/
public keys(): string[] {
if (this.resolvingKeys) {
throw new AlephaError(
`Circular $computed dependency detected at "${this.key}". A computed cannot depend on itself, even transitively.`,
);
}
this.resolvingKeys = true;
try {
const out: string[] = [];
for (const dep of this.options.deps as ReadonlyArray<AnyDep>) {
if (dep instanceof Computed) {
out.push(...dep.keys());
} else {
out.push(dep.key);
}
}
return [...new Set(out)];
} finally {
this.resolvingKeys = false;
}
}
/**
* Resolve the value using the given dependency resolver.
*/
public compute(resolve: (dep: AnyDep) => unknown): R {
if (this.computing) {
throw new AlephaError(
`Circular $computed dependency detected at "${this.key}". A computed cannot depend on itself, even transitively.`,
);
}
this.computing = true;
try {
const values = (this.options.deps as ReadonlyArray<AnyDep>).map(resolve);
return this.options.get(...(values as DepValues<any>));
} finally {
this.computing = false;
}
}
}
$computed[KIND] = "computed";