Skip to content

Commit

Permalink
feat(utils): atomFamily supports getParams and unstable_listen api (#…
Browse files Browse the repository at this point in the history
…2685)

* feat(atomFamily): support getParams and unstable_listen api

* Update tests/vanilla/utils/atomFamily.test.ts

* Update src/vanilla/utils/atomFamily.ts

Co-authored-by: Daishi Kato <[email protected]>

* call notifyListeners after adding to set

* add jsdoc comment for CreatedAt

---------

Co-authored-by: Daishi Kato <[email protected]>
  • Loading branch information
David Maskasky and dai-shi authored Aug 7, 2024
1 parent 9228a88 commit b4565cb
Show file tree
Hide file tree
Showing 2 changed files with 142 additions and 6 deletions.
53 changes: 47 additions & 6 deletions src/vanilla/utils/atomFamily.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import type { Atom } from '../../vanilla.ts'
import { type Atom } from '../../vanilla.ts'

type ShouldRemove<Param> = (createdAt: number, param: Param) => boolean
/**
* in milliseconds
*/
type CreatedAt = number
type ShouldRemove<Param> = (createdAt: CreatedAt, param: Param) => boolean
type Cleanup = () => void
type Callback<Param, AtomType> = (event: {
type: 'CREATE' | 'REMOVE'
param: Param
atom: AtomType
}) => void

export interface AtomFamily<Param, AtomType> {
(param: Param): AtomType
getParams(): Iterable<Param>
remove(param: Param): void
setShouldRemove(shouldRemove: ShouldRemove<Param> | null): void
/**
* fires when a atom is created or removed
* This API is for advanced use cases, and can change without notice.
*/
unstable_listen(callback: Callback<Param, AtomType>): Cleanup
}

export function atomFamily<Param, AtomType extends Atom<unknown>>(
Expand All @@ -17,9 +33,9 @@ export function atomFamily<Param, AtomType extends Atom<unknown>>(
initializeAtom: (param: Param) => AtomType,
areEqual?: (a: Param, b: Param) => boolean,
) {
type CreatedAt = number // in milliseconds
let shouldRemove: ShouldRemove<Param> | null = null
const atoms: Map<Param, [AtomType, CreatedAt]> = new Map()
const listeners = new Set<Callback<Param, AtomType>>()
const createAtom = (param: Param) => {
let item: [AtomType, CreatedAt] | undefined
if (areEqual === undefined) {
Expand All @@ -44,16 +60,40 @@ export function atomFamily<Param, AtomType extends Atom<unknown>>(

const newAtom = initializeAtom(param)
atoms.set(param, [newAtom, Date.now()])
notifyListeners('CREATE', param, newAtom)
return newAtom
}

function notifyListeners(
type: 'CREATE' | 'REMOVE',
param: Param,
atom: AtomType,
) {
for (const listener of listeners) {
listener({ type, param, atom })
}
}

createAtom.unstable_listen = (callback: Callback<Param, AtomType>) => {
listeners.add(callback)
return () => {
listeners.delete(callback)
}
}

createAtom.getParams = () => atoms.keys()

createAtom.remove = (param: Param) => {
if (areEqual === undefined) {
if (!atoms.has(param)) return
const [atom] = atoms.get(param)!
atoms.delete(param)
notifyListeners('REMOVE', param, atom)
} else {
for (const [key] of atoms) {
for (const [key, [atom]] of atoms) {
if (areEqual(key, param)) {
atoms.delete(key)
notifyListeners('REMOVE', key, atom)
break
}
}
Expand All @@ -63,9 +103,10 @@ export function atomFamily<Param, AtomType extends Atom<unknown>>(
createAtom.setShouldRemove = (fn: ShouldRemove<Param> | null) => {
shouldRemove = fn
if (!shouldRemove) return
for (const [key, value] of atoms) {
if (shouldRemove(value[1], key)) {
for (const [key, [atom, createdAt]] of atoms) {
if (shouldRemove(createdAt, key)) {
atoms.delete(key)
notifyListeners('REMOVE', key, atom)
}
}
}
Expand Down
95 changes: 95 additions & 0 deletions tests/vanilla/utils/atomFamily.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { expect, it, vi } from 'vitest'
import { atom, createStore } from 'jotai/vanilla'
import type { Atom } from 'jotai/vanilla'
import { atomFamily } from 'jotai/vanilla/utils'

it('should create atoms with different params', () => {
const store = createStore()
const aFamily = atomFamily((param: number) => atom(param))

expect(store.get(aFamily(1))).toEqual(1)
expect(store.get(aFamily(2))).toEqual(2)
})

it('should remove atoms', () => {
const store = createStore()
const initializeAtom = vi.fn((param: number) => atom(param))
const aFamily = atomFamily(initializeAtom)

expect(store.get(aFamily(1))).toEqual(1)
expect(store.get(aFamily(2))).toEqual(2)
aFamily.remove(2)
initializeAtom.mockClear()
expect(store.get(aFamily(1))).toEqual(1)
expect(initializeAtom).toHaveBeenCalledTimes(0)
expect(store.get(aFamily(2))).toEqual(2)
expect(initializeAtom).toHaveBeenCalledTimes(1)
})

it('should remove atoms with custom comparator', () => {
const store = createStore()
const initializeAtom = vi.fn((param: number) => atom(param))
const aFamily = atomFamily(initializeAtom, (a, b) => a === b)

expect(store.get(aFamily(1))).toEqual(1)
expect(store.get(aFamily(2))).toEqual(2)
expect(store.get(aFamily(3))).toEqual(3)
aFamily.remove(2)
initializeAtom.mockClear()
expect(store.get(aFamily(1))).toEqual(1)
expect(initializeAtom).toHaveBeenCalledTimes(0)
expect(store.get(aFamily(2))).toEqual(2)
expect(initializeAtom).toHaveBeenCalledTimes(1)
})

it('should remove atoms with custom shouldRemove', () => {
const store = createStore()
const initializeAtom = vi.fn((param: number) => atom(param))
const aFamily = atomFamily<number, Atom<number>>(initializeAtom)
expect(store.get(aFamily(1))).toEqual(1)
expect(store.get(aFamily(2))).toEqual(2)
expect(store.get(aFamily(3))).toEqual(3)
aFamily.setShouldRemove((_createdAt, param) => param % 2 === 0)
initializeAtom.mockClear()
expect(store.get(aFamily(1))).toEqual(1)
expect(initializeAtom).toHaveBeenCalledTimes(0)
expect(store.get(aFamily(2))).toEqual(2)
expect(initializeAtom).toHaveBeenCalledTimes(1)
expect(store.get(aFamily(3))).toEqual(3)
expect(initializeAtom).toHaveBeenCalledTimes(1)
})

it('should notify listeners', () => {
const aFamily = atomFamily((param: number) => atom(param))
const listener = vi.fn(() => {})
type Event = { type: 'CREATE' | 'REMOVE'; param: number; atom: Atom<number> }
const unsubscribe = aFamily.unstable_listen(listener)
const atom1 = aFamily(1)
expect(listener).toHaveBeenCalledTimes(1)
const eventCreate = listener.mock.calls[0]?.at(0) as unknown as Event
if (!eventCreate) throw new Error('eventCreate is undefined')
expect(eventCreate.type).toEqual('CREATE')
expect(eventCreate.param).toEqual(1)
expect(eventCreate.atom).toEqual(atom1)
listener.mockClear()
aFamily.remove(1)
expect(listener).toHaveBeenCalledTimes(1)
const eventRemove = listener.mock.calls[0]?.at(0) as unknown as Event
expect(eventRemove.type).toEqual('REMOVE')
expect(eventRemove.param).toEqual(1)
expect(eventRemove.atom).toEqual(atom1)
unsubscribe()
listener.mockClear()
aFamily(2)
expect(listener).toHaveBeenCalledTimes(0)
})

it('should return all params', () => {
const store = createStore()
const aFamily = atomFamily((param: number) => atom(param))

expect(store.get(aFamily(1))).toEqual(1)
expect(store.get(aFamily(2))).toEqual(2)
expect(store.get(aFamily(3))).toEqual(3)
expect(Array.from(aFamily.getParams())).toEqual([1, 2, 3])
})

0 comments on commit b4565cb

Please sign in to comment.