diff --git a/.changeset/dedupe-with.md b/.changeset/dedupe-with.md new file mode 100644 index 0000000..124476e --- /dev/null +++ b/.changeset/dedupe-with.md @@ -0,0 +1,19 @@ +--- +"@effect-atom/atom": minor +--- + +Add `Atom.dedupeWith` combinator for customizing write equivalence. + +By default every atom deduplicates writes using `Equal.equals`, which falls back +to reference equality (`===`) for plain objects and arrays. `dedupeWith` lets +you attach a custom `Equivalence` so that structurally-equal writes become +no-ops — useful for atoms holding API payloads, derived arrays, or any value +type that does not implement `Equal`. + +```ts +import { Atom } from "@effect-atom/atom" + +const user = Atom.make({ id: "u1", name: "Alice" }).pipe( + Atom.dedupeWith((a, b) => a.id === b.id && a.name === b.name) +) +``` diff --git a/packages/atom/src/Atom.ts b/packages/atom/src/Atom.ts index 08726e2..2de4676 100644 --- a/packages/atom/src/Atom.ts +++ b/packages/atom/src/Atom.ts @@ -13,6 +13,8 @@ import * as EffectContext from "effect/Context" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" import * as Either from "effect/Either" +import * as Equal from "effect/Equal" +import type * as Equivalence from "effect/Equivalence" import * as Exit from "effect/Exit" import * as Fiber from "effect/Fiber" import * as FiberId from "effect/FiberId" @@ -55,6 +57,7 @@ export interface Atom extends Pipeable, Inspectable.Inspectable { readonly refresh?: (f: (atom: Atom) => void) => void readonly label?: readonly [name: string, stack: string] readonly idleTTL?: number + readonly eq: Equivalence.Equivalence } /** @@ -172,8 +175,38 @@ export const setIdleTTL: { const removeTtl = setIdleTTL(0) +/** + * Override the equivalence used to deduplicate writes to this atom. + * + * By default every atom uses `Equal.equals`, which short-circuits writes when + * the new value is structurally equal to the previous one. For plain objects + * and arrays that do not implement `Equal`, `Equal.equals` falls back to + * reference equality (`===`), causing every new reference — even a + * structurally identical one — to notify subscribers and invalidate + * downstream atoms. + * + * `dedupeWith` lets you supply a custom `Equivalence` (e.g. a deep-equality + * check, an id-based check, or a domain-specific comparison) so that writes + * yielding an "equivalent" value become no-ops. + * + * @since 1.0.0 + * @category combinators + */ +export const dedupeWith: { + (eq: Equivalence.Equivalence): >(self: Self) => Self + >(self: Self, eq: Equivalence.Equivalence>): Self +} = dual< + (eq: Equivalence.Equivalence) => >(self: Self) => Self, + >(self: Self, eq: Equivalence.Equivalence>) => Self +>(2, (self, eq) => + Object.assign(Object.create(Object.getPrototypeOf(self)), { + ...self, + eq + })) + const AtomProto = { [TypeId]: TypeId, + eq: Equal.equals, pipe() { return pipeArguments(this, arguments) }, diff --git a/packages/atom/src/internal/registry.ts b/packages/atom/src/internal/registry.ts index 1e71203..364ed71 100644 --- a/packages/atom/src/internal/registry.ts +++ b/packages/atom/src/internal/registry.ts @@ -1,5 +1,4 @@ import * as Effect from "effect/Effect" -import * as Equal from "effect/Equal" import * as Exit from "effect/Exit" import { constVoid, pipe } from "effect/Function" import { globalValue } from "effect/GlobalValue" @@ -341,7 +340,7 @@ class Node { } this.state = NodeState.valid - if (Equal.equals(this._value, value)) { + if (this.atom.eq(this._value, value)) { return } diff --git a/packages/atom/test/Atom.test.ts b/packages/atom/test/Atom.test.ts index 50d0748..506d753 100644 --- a/packages/atom/test/Atom.test.ts +++ b/packages/atom/test/Atom.test.ts @@ -1628,6 +1628,99 @@ describe("Atom", () => { expect(storage.get("test-key")).toEqual(JSON.stringify(42)) }) }) + + describe("dedupeWith", () => { + it("plain-object atom without dedupeWith re-notifies on structurally-equal new reference", () => { + const atom = Atom.make({ n: 1 }) + const r = Registry.make() + r.get(atom) + let count = 0 + const cancel = r.subscribe(atom, () => { + count++ + }) + r.set(atom, { n: 1 }) + r.set(atom, { n: 1 }) + expect(count).toEqual(2) + cancel() + }) + + it("dedupeWith with deep equality suppresses notify on structurally-equal writes", () => { + const eq = (a: { n: number }, b: { n: number }) => a.n === b.n + const atom = Atom.make({ n: 1 }).pipe(Atom.dedupeWith(eq)) + const r = Registry.make() + r.get(atom) + let count = 0 + const cancel = r.subscribe(atom, () => { + count++ + }) + r.set(atom, { n: 1 }) + r.set(atom, { n: 1 }) + expect(count).toEqual(0) + r.set(atom, { n: 2 }) + expect(count).toEqual(1) + cancel() + }) + + it("dedupeWith does not suppress the initial value", () => { + const atom = Atom.make({ n: 1 }).pipe( + Atom.dedupeWith((a: { n: number }, b: { n: number }) => a.n === b.n) + ) + const r = Registry.make() + expect(r.get(atom)).toEqual({ n: 1 }) + }) + + it("dedupeWith prevents downstream invalidation of derived atoms", () => { + const source = Atom.make({ n: 1 }).pipe( + Atom.keepAlive, + Atom.dedupeWith((a: { n: number }, b: { n: number }) => a.n === b.n) + ) + let derivations = 0 + const derived = Atom.readable((get) => { + derivations++ + return get(source).n * 2 + }).pipe(Atom.keepAlive) + const r = Registry.make() + expect(r.get(derived)).toEqual(2) + expect(derivations).toEqual(1) + r.set(source, { n: 1 }) + expect(r.get(derived)).toEqual(2) + expect(derivations).toEqual(1) + r.set(source, { n: 3 }) + expect(r.get(derived)).toEqual(6) + expect(derivations).toEqual(2) + }) + + it("composes with setIdleTTL in either order", () => { + const eq = (a: { n: number }, b: { n: number }) => a.n === b.n + const a = Atom.make({ n: 1 }).pipe( + Atom.dedupeWith(eq), + Atom.setIdleTTL("1 second") + ) + const b = Atom.make({ n: 1 }).pipe( + Atom.setIdleTTL("1 second"), + Atom.dedupeWith(eq) + ) + expect(a.eq).toBe(eq) + expect(b.eq).toBe(eq) + expect(a.idleTTL).toEqual(1000) + expect(b.idleTTL).toEqual(1000) + }) + + it("default eq is Equal.equals — primitive writes still deduplicate unchanged", () => { + const atom = Atom.make(1) + const r = Registry.make() + r.get(atom) + let count = 0 + const cancel = r.subscribe(atom, () => { + count++ + }) + r.set(atom, 1) + expect(count).toEqual(0) + r.set(atom, 2) + expect(count).toEqual(1) + cancel() + }) + }) }) interface BuildCounter {