From 4d4979daa9bb6436489c3c5e0428e5a22febd091 Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 3 Mar 2024 22:50:59 -0800 Subject: [PATCH] feat!: replace `split` with `mapAtom` --- README.md | 4 +- src/__tests__/mapAtom.spec.luau | 105 ++++++++++++++++++++++++ src/__tests__/split.spec.luau | 136 -------------------------------- src/atom.luau | 8 +- src/derive.luau | 3 +- src/index.d.ts | 31 ++++---- src/init.luau | 6 +- src/mapAtom.luau | 56 +++++++++++++ src/observe.luau | 1 - src/split.luau | 82 ------------------- src/subscribe.luau | 1 - 11 files changed, 188 insertions(+), 245 deletions(-) create mode 100644 src/__tests__/mapAtom.spec.luau delete mode 100644 src/__tests__/split.spec.luau create mode 100644 src/mapAtom.luau delete mode 100644 src/split.luau diff --git a/README.md b/README.md index b3307dd..2f447fc 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Charm = "littensy/charm@VERSION" #### `observe(atom, observer)` -#### `split(atom, keyExtractor?)` +#### `mapAtom(atom, mapper)` ### Atom @@ -67,7 +67,7 @@ Charm = "littensy/charm@VERSION" #### `Atom.set(state)` -#### `Atom.memo(equalityFn)` +#### `Atom.memo(areEqual)` #### `Atom.readonly()` diff --git a/src/__tests__/mapAtom.spec.luau b/src/__tests__/mapAtom.spec.luau new file mode 100644 index 0000000..05ec132 --- /dev/null +++ b/src/__tests__/mapAtom.spec.luau @@ -0,0 +1,105 @@ +local atom = require(script.Parent.Parent.atom) +local mapAtom = require(script.Parent.Parent.mapAtom) +local observe = require(script.Parent.Parent.observe) + +return function() + it("returns mapped atoms", function() + local itemsAtom = atom({ "a", "b", "c" }) + local mappedAtom = mapAtom(itemsAtom, function(value, key) + return string.upper(value), key + end) + local updates = 0 + + mappedAtom.listeners[function() + updates += 1 + end] = true + + for key = 1, 3 do + local item = mappedAtom()[key] + expect(item).to.equal(string.upper(itemsAtom()[key])) + end + + itemsAtom({ "d", "e", "f" }) + expect(updates).to.equal(1) + + for key = 1, 3 do + local item = mappedAtom()[key] + expect(item).to.equal(string.upper(itemsAtom()[key])) + end + + itemsAtom({ [1] = "d", [3] = "f" }) + expect(mappedAtom()[2]).to.never.be.ok() + expect(updates).to.equal(2) + end) + + it("uses key extractor", function() + local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } }) + local mappedAtom = mapAtom(itemsAtom, function(item) + return item, item.key + end) + + for key = 1, 3 do + local item = mappedAtom()[key] + expect(item).to.equal(itemsAtom()[key]) + end + + local previous = mappedAtom() + itemsAtom({ itemsAtom()[1], itemsAtom()[3] }) + + expect(mappedAtom()[1]).to.equal(previous[1]) + expect(mappedAtom()[1]).to.equal(itemsAtom()[1]) + expect(mappedAtom()[2]).to.never.be.ok() + expect(mappedAtom()[3]).to.equal(previous[3]) + expect(mappedAtom()[3]).to.equal(itemsAtom()[2]) + end) + + it("allows observe()", function() + local itemsAtom = atom({ + { key = 1, value = "a" }, + { key = 2, value = "b" }, + { key = 3, value = "c" }, + }) + + local mappedAtom = mapAtom(itemsAtom, function(item) + return item, item.key + end) + + local added = 0 + local deleted = 0 + + local _, flush = observe(mappedAtom, function(item, key) + expect(item).to.equal(itemsAtom()[key]) + added += 1 + + return function() + expect(mappedAtom()[key]).to.never.be.ok() + deleted += 1 + end + end) + + expect(added).to.equal(3) + expect(deleted).to.equal(0) + + itemsAtom({ itemsAtom()[1], itemsAtom()[3] }) + flush() + + expect(added).to.equal(3) + expect(deleted).to.equal(1) + + itemsAtom({}) + flush() + + expect(added).to.equal(3) + expect(deleted).to.equal(3) + end) + + it("unmounts from parent atom", function() + local itemsAtom = atom({}) + local mappedAtom = mapAtom(itemsAtom, function(item, key) + return item, key + end) + expect((next(itemsAtom.listeners))).to.be.ok() + mappedAtom:unmount() + expect((next(itemsAtom.listeners))).to.never.be.ok() + end) +end diff --git a/src/__tests__/split.spec.luau b/src/__tests__/split.spec.luau deleted file mode 100644 index 1504054..0000000 --- a/src/__tests__/split.spec.luau +++ /dev/null @@ -1,136 +0,0 @@ -local atom = require(script.Parent.Parent.atom) -local isAtom = require(script.Parent.Parent.isAtom) -local observe = require(script.Parent.Parent.observe) -local split = require(script.Parent.Parent.split) - -return function() - it("returns split atoms", function() - local itemsAtom = atom({ "a", "b", "c" }) - local itemAtoms = split(itemsAtom) - local updates = 0 - - itemAtoms.listeners[function() - updates += 1 - end] = true - - for key = 1, 3 do - local itemAtom = itemAtoms()[key] - assert(isAtom(itemAtom), `itemAtom {key} is not an atom; got {typeof(itemAtom)}`) - expect(itemAtom()).to.equal(itemsAtom()[key]) - end - - local prevAtoms = itemAtoms() - itemsAtom({ "d", "e", "f" }) - expect(updates).to.equal(1) - - for key = 1, 3 do - local itemAtom = itemAtoms()[key] - assert(itemAtom == prevAtoms[key] :: any, `itemAtom {key} is not the same atom; got {typeof(itemAtom)}`) - expect(itemAtom()).to.equal(itemsAtom()[key]) - end - - itemsAtom({ [1] = "d", [3] = "f" }) - expect(itemAtoms()[2]).to.never.be.ok() - expect(updates).to.equal(2) - end) - - it("has writable items", function() - local itemsAtom = atom({ "a", "b", "c" }) - local itemAtoms = split(itemsAtom) - local updates = 0 - - itemAtoms.listeners[function() - updates += 1 - end] = true - - itemAtoms()[2]("x") - expect(updates).to.equal(1) - expect(itemsAtom()[2]).to.equal("x") - expect(itemAtoms()[2]()).to.equal("x") - end) - - it("uses key extractor", function() - local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } }) - local itemAtoms = split(itemsAtom, function(item) - return item.key - end) - - for key = 1, 3 do - local itemAtom = itemAtoms()[key] - expect(itemAtom()).to.equal(itemsAtom()[key]) - end - - local prevAtoms = itemAtoms() - itemsAtom({ itemsAtom()[1], itemsAtom()[3] }) - - expect(itemAtoms()[1]).to.equal(prevAtoms[1]) - expect(itemAtoms()[1]()).to.equal(itemsAtom()[1]) - expect(itemAtoms()[2]).to.never.be.ok() - expect(itemAtoms()[3]).to.equal(prevAtoms[3]) - expect(itemAtoms()[3]()).to.equal(itemsAtom()[2]) - end) - - it("allows observe()", function() - local itemsAtom = atom({ - { key = 1, value = "a" }, - { key = 2, value = "b" }, - { key = 3, value = "c" }, - }) - local itemAtoms = split(itemsAtom, function(item) - return item.key - end) - - local added = 0 - local updated = 0 - local deleted = 0 - - local _, flush = observe(itemAtoms, function(item, key) - expect(item()).to.equal(itemsAtom()[key]) - - item.listeners[function(value) - expect(value).to.equal(itemsAtom()[key]) - updated += 1 - end] = true - - added += 1 - - return function() - expect(itemAtoms()[key]).to.never.be.ok() - deleted += 1 - end - end) - - expect(added).to.equal(3) - expect(updated).to.equal(0) - expect(deleted).to.equal(0) - - itemsAtom({ itemsAtom()[1], itemsAtom()[3] }) - flush() - - expect(added).to.equal(3) - expect(updated).to.equal(0) - expect(deleted).to.equal(1) - - itemAtoms()[1]({ key = 1, value = "x" }) - flush() - - expect(added).to.equal(3) - expect(updated).to.equal(1) - expect(deleted).to.equal(1) - - itemsAtom({}) - flush() - - expect(added).to.equal(3) - expect(updated).to.equal(1) - expect(deleted).to.equal(3) - end) - - it("unmounts from parent atom", function() - local itemsAtom = atom({}) - local itemAtoms = split(itemsAtom) - expect((next(itemsAtom.listeners))).to.be.ok() - itemAtoms:unmount() - expect((next(itemsAtom.listeners))).to.never.be.ok() - end) -end diff --git a/src/atom.luau b/src/atom.luau index 211bac2..d26d976 100644 --- a/src/atom.luau +++ b/src/atom.luau @@ -11,7 +11,7 @@ type AtomInternal = { export type Atom = (() -> T) & ((state: T | ((T) -> T)) -> ()) & { get: ((self: Atom) -> T) & ((self: Atom, selector: (T) -> Result) -> Result), set: (self: Atom, state: T | ((T) -> T)) -> (), - memo: (self: Atom, equalityFn: (previous: T, current: T) -> boolean) -> Atom, + memo: (self: Atom, areEqual: (previous: T, current: T) -> boolean) -> Atom, writable: (self: Atom, onSet: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom, readonly: (self: Atom) -> ReadonlyAtom, named: (self: Atom, label: string) -> Atom, @@ -20,7 +20,7 @@ export type Atom = (() -> T) & ((state: T | ((T) -> T)) -> ()) & { export type ReadonlyAtom = (() -> T) & { get: ((self: ReadonlyAtom) -> T) & ((self: ReadonlyAtom, selector: (T) -> Result) -> Result), - memo: (self: ReadonlyAtom, equalityFn: (previous: T, current: T) -> boolean) -> ReadonlyAtom, + memo: (self: ReadonlyAtom, areEqual: (previous: T, current: T) -> boolean) -> ReadonlyAtom, writable: ((self: Atom) -> Atom) & ((self: Atom, onSet: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom), readonly: (self: ReadonlyAtom) -> ReadonlyAtom, @@ -81,8 +81,8 @@ local function atom(state: State): Atom end end - function atom:memo(equalityFn: (State, State) -> boolean): Atom - equals = equalityFn + function atom:memo(areEqual: (State, State) -> boolean): Atom + equals = areEqual return self end diff --git a/src/derive.luau b/src/derive.luau index 9d07458..b67d2c9 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -1,7 +1,6 @@ local atom = require(script.Parent.atom) type Atom = atom.Atom type ReadonlyAtom = atom.ReadonlyAtom - local reference = require(script.Parent.utils.reference) type Derive = @@ -29,7 +28,7 @@ local function derive(...): ReadonlyAtom local getDerivedAtom = reference.weak(derivedAtom) function derivedAtom:unmount() - derivedAtomUnmount(derivedAtom) + derivedAtomUnmount(self) for listener in listeners do for _, atom in atoms do diff --git a/src/index.d.ts b/src/index.d.ts index 3e131d2..127055e 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -17,7 +17,7 @@ declare namespace Charm { get(): State; get(selector: (state: State) => Result): Result; set(state: State | ((current: State) => State)): void; - memo(equalityFn: (previous: State, current: State) => boolean): this; + memo(areEqual: (previous: State, current: State) => boolean): this; writable(onSet?: (update: State, previous: State) => State | void): Atom; readonly(): ReadonlyAtom; named(label: string): this; @@ -55,22 +55,25 @@ declare namespace Charm { function observe(atom: Atom, factory: (value: unknown, key: unknown) => (() => void) | void): () => void; - function splitAtom( - listAtom: Atom> | Atom>, - keyExtractor?: (item: Item) => Id, - ): Atom>>; + function mapAtom( + atom: Atom | Atom, + mapper: (value: V0, index: number) => LuaTuple<[V1 | undefined, K1]>, + ): ReadonlyAtom>; - function splitAtom(listAtom: Atom): Atom[]>; + function mapAtom( + atom: Atom | Atom, + mapper: (value: V0, index: number) => V1, + ): ReadonlyAtom; - function splitAtom( - listAtom: Atom, - keyExtractor?: (item: Item) => Id, - ): Atom>>; + function mapAtom( + atom: Atom> | Atom>, + mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1, + ): ReadonlyAtom>; - function splitAtom( - listAtom: Atom<{ readonly [K in Key]?: Item }>, - keyExtractor?: (item: Item) => Id, - ): Atom>>; + function mapAtom( + atom: Atom<{ readonly [K in K0]: V0 }>, + mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1, + ): ReadonlyAtom>; function useAtomState(atom: Atom): State; diff --git a/src/init.luau b/src/init.luau index 38fa12d..95549b4 100644 --- a/src/init.luau +++ b/src/init.luau @@ -3,12 +3,12 @@ _G[script.modules.Promise] = _G[script] local atom = require(script.atom) local derive = require(script.derive) local isAtom = require(script.isAtom) +local mapAtom = require(script.mapAtom) local observe = require(script.observe) -local split = require(script.split) -local subscribe = require(script.subscribe) local useAtom = require(script.react.useAtom) local useAtomState = require(script.react.useAtomState) local useSetAtom = require(script.react.useSetAtom) +local subscribe = require(script.subscribe) local sync = require(script.sync) export type Atom = atom.Atom @@ -19,7 +19,7 @@ return { derive = derive, isAtom = isAtom, observe = observe, - split = split, + mapAtom = mapAtom, subscribe = subscribe, sync = sync, useAtom = useAtom, diff --git a/src/mapAtom.luau b/src/mapAtom.luau new file mode 100644 index 0000000..572455d --- /dev/null +++ b/src/mapAtom.luau @@ -0,0 +1,56 @@ +local atom = require(script.Parent.atom) +type Atom = atom.Atom +type ReadonlyAtom = atom.ReadonlyAtom +local reference = require(script.Parent.utils.reference) + +local function mapAtom(itemsAtom: Atom<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): ReadonlyAtom<{ [K1]: V1 }> + local mappedAtom: ReadonlyAtom<{ [K1]: V1 }> = atom({}):readonly() + local mappedAtomUnmount = mappedAtom.unmount + local getMappedAtom = reference.weak(mappedAtom) + local previousMappedItems: { [K1]: V1 } = {} + + local function listener(items) + local mappedAtom = getMappedAtom() + + if not mappedAtom then + itemsAtom.listeners[listener] = nil + return + end + + local mappedItems = table.clone(mappedAtom:get()) + local mappedKeys = {} + + for key, item in items do + local newItem, newKey = mapper(item, key) + if newKey == nil then + newKey = key :: any + end + if mappedItems[newKey :: K1] ~= newItem then + mappedItems[newKey :: K1] = newItem :: V1 + else + mappedKeys[newKey] = key + end + end + + for key in previousMappedItems do + if mappedKeys[key] == nil and mappedItems[key] == previousMappedItems[key] then + mappedItems[key] = nil + end + end + + previousMappedItems = mappedItems + mappedAtom:setForced(mappedItems) + end + + function mappedAtom:unmount() + mappedAtomUnmount(self) + itemsAtom.listeners[listener] = nil + end + + itemsAtom.listeners[listener] = true + listener(itemsAtom:get()) + + return mappedAtom +end + +return mapAtom diff --git a/src/observe.luau b/src/observe.luau index e6f78ff..a8619df 100644 --- a/src/observe.luau +++ b/src/observe.luau @@ -1,6 +1,5 @@ local atom = require(script.Parent.atom) type Atom = atom.Atom - local subscribe = require(script.Parent.subscribe) local function observe(atom: Atom<{ [Key]: Item }>, factory: (value: Item, key: Key) -> () -> ()): (() -> (), () -> ()) diff --git a/src/split.luau b/src/split.luau deleted file mode 100644 index 92a4477..0000000 --- a/src/split.luau +++ /dev/null @@ -1,82 +0,0 @@ -local atom = require(script.Parent.atom) -type Atom = atom.Atom -type ReadonlyAtom = atom.ReadonlyAtom - -local reference = require(script.Parent.utils.reference) - -type Split = - ((atom: Atom<{ [Key]: Item }>) -> ReadonlyAtom<{ [Key]: Atom }>) - & ((atom: Atom<{ [any]: Item }>, keyExtractor: (Item) -> Key) -> ReadonlyAtom<{ [Key]: Atom }>) - -local function split( - itemsAtom: Atom<{ [Key]: Item }>, - keyExtractor: ((Item) -> Key)? -): ReadonlyAtom<{ [Key]: Atom }> - local splitAtom: ReadonlyAtom<{ [Key]: Atom }> = atom({}):readonly() - local splitAtomUnmount = splitAtom.unmount - local getSplitAtom = reference.weak(splitAtom) - - local previousAtoms: { [Key]: Atom } = {} - local previousItems = itemsAtom:get() - local listener: ({ [Key]: Item }) -> () - - local function cleanup() - itemsAtom.listeners[listener] = nil - for _, atom in previousAtoms do - atom:unmount() - end - end - - function listener(items) - local splitAtom = getSplitAtom() - - if not splitAtom then - return cleanup() - end - - local atoms = table.clone(splitAtom:get()) - local itemsByKeys: { [Key]: Item } = {} - - for key, item in items do - local id = if keyExtractor then keyExtractor(item) else key - - itemsByKeys[id] = item - - if atoms[id] then - -- Force set the atom to avoid redundantly updating itemsAtom - atoms[id]:setForced(item) - else - atoms[id] = atom(item):writable(function(update) - local nextItems = table.clone(itemsAtom:get()) - nextItems[key] = update - itemsAtom:set(nextItems) - end) - end - end - - for key, item in previousItems do - local id = if keyExtractor then keyExtractor(item) else key - local atom = atoms[id] - - if itemsByKeys[id] == nil and atom then - atom:unmount() - atoms[id] = nil - end - end - - previousAtoms, previousItems = atoms, items - splitAtom:setForced(atoms) - end - - function splitAtom:unmount() - splitAtomUnmount(splitAtom) - cleanup() - end - - itemsAtom.listeners[listener] = true - listener(itemsAtom:get()) - - return splitAtom -end - -return (split :: any) :: Split diff --git a/src/subscribe.luau b/src/subscribe.luau index be2b074..1f80104 100644 --- a/src/subscribe.luau +++ b/src/subscribe.luau @@ -1,6 +1,5 @@ local atom = require(script.Parent.atom) type Atom = atom.Atom - local reference = require(script.Parent.utils.reference) local function subscribe(atom: Atom, callback: (state: State, previous: State) -> ()): (() -> (), () -> ())