Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/dedupe-with.md
Original file line number Diff line number Diff line change
@@ -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)
)
```
33 changes: 33 additions & 0 deletions packages/atom/src/Atom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -55,6 +57,7 @@ export interface Atom<A> extends Pipeable, Inspectable.Inspectable {
readonly refresh?: (f: <A>(atom: Atom<A>) => void) => void
readonly label?: readonly [name: string, stack: string]
readonly idleTTL?: number
readonly eq: Equivalence.Equivalence<A>
}

/**
Expand Down Expand Up @@ -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: {
<A>(eq: Equivalence.Equivalence<A>): <Self extends Atom<A>>(self: Self) => Self
<Self extends Atom<any>>(self: Self, eq: Equivalence.Equivalence<Type<Self>>): Self
} = dual<
<A>(eq: Equivalence.Equivalence<A>) => <Self extends Atom<A>>(self: Self) => Self,
<Self extends Atom<any>>(self: Self, eq: Equivalence.Equivalence<Type<Self>>) => 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)
},
Expand Down
3 changes: 1 addition & 2 deletions packages/atom/src/internal/registry.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -341,7 +340,7 @@ class Node<A> {
}

this.state = NodeState.valid
if (Equal.equals(this._value, value)) {
if (this.atom.eq(this._value, value)) {
return
}

Expand Down
93 changes: 93 additions & 0 deletions packages/atom/test/Atom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down