refactor!: use atom functions

This commit is contained in:
Richard 2024-03-25 02:18:10 -07:00
parent 4d4979daa9
commit 5be8e27296
29 changed files with 362 additions and 574 deletions

View file

@ -47,55 +47,37 @@ Charm = "littensy/charm@VERSION"
## 📚 Documentation ## 📚 Documentation
### Core ### `atom(state)`
#### `atom(state)` ### `derive(atom, comparator?)`
#### `derive(...atoms, combiner)` ### `subscribe(atom, listener)`
#### `subscribe(atom, listener)` ### `effect(callback)`
#### `observe(atom, observer)` ### `observe(atom, observer)`
#### `mapAtom(atom, mapper)` ### `mapAtom(atom, mapper)`
### Atom ## 📚 React API
#### `Atom(state?)` ### `useAtom(atom, selector?)`
#### `Atom.get(selector?)` ### `useAtomState(atom)`
#### `Atom.set(state)` ### `useSetAtom(atom)`
#### `Atom.memo(areEqual)` ## 📚 Sync API
#### `Atom.readonly()` ### `sync.client(options)`
#### `Atom.writable(onSet?)` ### `sync.server(options)`
#### `Atom.named(label)` ### `Client.sync(payload)`
#### `Atom.unmount()` ### `Server.onSync(callback)`
### React ### `Server.hydrate(player)`
#### `useAtom(atom, selector?)`
#### `useAtomState(atom)`
#### `useSetAtom(atom)`
### Sync
#### `sync.client(options)`
#### `Client.sync(payload)`
#### `sync.server(options)`
#### `Server.onSync(callback)`
#### `Server.hydrate(player)`
## 🚀 Examples ## 🚀 Examples
@ -105,7 +87,7 @@ Charm = "littensy/charm@VERSION"
import { atom, derive, subscribe } from "@rbxts/charm"; import { atom, derive, subscribe } from "@rbxts/charm";
const counterAtom = atom(0); const counterAtom = atom(0);
const doubleCounterAtom = derive(counterAtom, (counter) => counter * 2); const doubleCounterAtom = derive(() => counterAtom() * 2);
subscribe(doubleCounterAtom, (value) => { subscribe(doubleCounterAtom, (value) => {
print(value); print(value);
@ -137,12 +119,6 @@ function Counter() {
} }
``` ```
## 🙋 Concerns
In Jotai, `atom()` returns an [atom config](https://jotai.org/docs/core/atom), and its state is managed entirely by the Provider. However, in Charm, `atom()` returns something closer to a store, and its state is managed by the atom itself.
Charm does this for simplicity in use cases outside of React, but this might go against Jotai's focus on pure functional programming. More insight is needed to determine if this is a good idea.
--- ---
<p align="center"> <p align="center">

View file

@ -1,4 +1,5 @@
local atom = require(script.Parent.Parent.atom) local atom = require(script.Parent.Parent.atom)
local store = require(script.Parent.Parent.store)
return function() return function()
it("updates state", function() it("updates state", function()
@ -14,10 +15,10 @@ return function()
it("calls listeners", function() it("calls listeners", function()
local source = atom(1) local source = atom(1)
local value local value
local function listener(state) local function listener()
value = state value = source()
end end
source.listeners[listener] = true store.listeners[source][listener] = true
source(2) source(2)
expect(value).to.equal(2) expect(value).to.equal(2)
end) end)
@ -31,9 +32,9 @@ return function()
local function listener() local function listener()
inner += 1 inner += 1
end end
source.listeners[listener] = true store.listeners[source][listener] = true
end end
source.listeners[listener] = true store.listeners[source][listener] = true
source(2) source(2)
expect(outer).to.equal(1) expect(outer).to.equal(1)
expect(inner).to.equal(0) expect(inner).to.equal(0)
@ -41,45 +42,4 @@ return function()
expect(outer).to.equal(2) expect(outer).to.equal(2)
expect(inner).to.equal(1) expect(inner).to.equal(1)
end) end)
it("can be readonly", function()
local readonly = atom(1):readonly()
expect(function()
(readonly :: any)(2)
end).to.throw()
local writable = readonly:writable()
writable(2)
expect(writable()).to.equal(2)
end)
it("calls the setter", function()
local source = atom(1):writable(function(_, value: number)
return value + 1
end)
source(1)
expect(source()).to.equal(2)
end)
it("cancels if setter returns nil", function()
local source = atom(1):writable(function(value: number)
return if value == 2 then nil else value
end)
source(2)
expect(source()).to.equal(1)
source(3)
expect(source()).to.equal(3)
end)
it("can be memoized", function()
local source = atom(1):memo(function(prev, next)
return next < prev
end)
source(2)
expect(source()).to.equal(2)
source(1)
expect(source()).to.equal(2)
end)
end end

View file

@ -6,48 +6,39 @@ local Package = game:GetService("ReplicatedStorage").Packages.charm
local atom = require(Package.atom) local atom = require(Package.atom)
local derive = require(Package.derive) local derive = require(Package.derive)
return { local function parameterGenerator()
ParameterGenerator = function() return atom({ count = 0 })
return atom({ count = 0 }) end
local benchmarks = {
atom = function(_, source: any)
for _ = 1, 500 do
source({ count = math.random(1, 2) })
end
end, end,
Functions = { derived = function(_, source: any)
atom = function(_, source: any) derive(function()
for _ = 1, 500 do return { count = 2 * source().count }
source({ count = math.random(1, 2) }) end)
end for _ = 1, 500 do
end, source({ count = math.random(1, 2) })
end
end,
derived = function(_, source: any) derived_memo = function(_, source: any)
derive(source, function(state) derive(function()
return { count = 2 * state.count } return { count = 2 * source().count }
end) end, function(previous, current)
for _ = 1, 500 do return previous.count == current.count
source({ count = math.random(1, 2) }) end)
end for _ = 1, 500 do
end, source({ count = math.random(1, 2) })
end
derived_memo = function(_, source: any) end,
source:memo(function(previous, current) }
return previous.count == current.count
end) return {
derive(source, function(state) ParameterGenerator = parameterGenerator,
return { count = 2 * state.count } Functions = benchmarks,
end)
for _ = 1, 500 do
source({ count = math.random(1, 2) })
end
end,
derived_writable = function(_, source: any)
local derived = derive(source, function(state)
return { count = 2 * state.count }
end):writable(function(update)
source({ count = 0.5 * update.count })
end)
for _ = 1, 500 do
derived({ count = 2 * math.random(1, 2) })
end
end,
},
} }

View file

@ -1,64 +1,53 @@
local atom = require(script.Parent.Parent.atom) local atom = require(script.Parent.Parent.atom)
local derive = require(script.Parent.Parent.derive) local derive = require(script.Parent.Parent.derive)
local store = require(script.Parent.Parent.store)
return function() return function()
it("derives from dependencies", function()
local a = atom(1)
local b = atom(2)
local sum = derive(a, b, function(a: number, b: number)
return a + b
end)
expect(sum()).to.equal(3)
a(2)
expect(sum()).to.equal(4)
b(3)
expect(sum()).to.equal(5)
expect(function()
(sum :: any)(2)
end).to.throw()
end)
it("memoizes the result", function() it("memoizes the result", function()
local calls = 0 local calls = 0
local source = atom(1) local a = atom(1)
local double = derive(source, function(value: number) local b = atom(2)
local double = derive(function()
calls += 1 calls += 1
return value * 2 return a() * b()
end) end)
expect(calls).to.equal(1) expect(calls).to.equal(1)
source(1) a(1)
expect(calls).to.equal(1) expect(calls).to.equal(1)
source(2) a(2)
expect(calls).to.equal(2) expect(calls).to.equal(2)
b(3)
expect(calls).to.equal(3)
double() double()
expect(calls).to.equal(2) expect(calls).to.equal(3)
end) end)
it("can be a dependency", function() it("takes a custom comparator", function()
local source = atom(1) local source = atom(1)
local double = derive(source, function(value: number) local derived = derive(function()
return value * 2 return source()
end, function(prev, next)
return next < prev
end) end)
local quadruple = derive(double, function(value: number) source(2)
return value * 2 expect(derived()).to.equal(2)
source(1)
expect(derived()).to.equal(2)
end)
it("can be nested", function()
local source = atom(1)
local double = derive(function()
return source() * 2
end)
local quadruple = derive(function()
return double() * 2
end) end)
expect(quadruple()).to.equal(4) expect(quadruple()).to.equal(4)
source(2) source(2)
expect(quadruple()).to.equal(8) expect(quadruple()).to.equal(8)
end) end)
it("allows custom setter", function()
local source = atom(1)
local double = derive(source, function(value: number)
return value * 2
end):writable(function(update: number)
source(update / 2)
end)
double(4)
expect(source()).to.equal(2)
expect(double()).to.equal(4)
end)
it("garbage collects", function() it("garbage collects", function()
-- large values coerce the garbage collector to run -- large values coerce the garbage collector to run
local source = atom(string.rep("x", 1e5)) local source = atom(string.rep("x", 1e5))
@ -66,7 +55,7 @@ return function()
local function listenersDecreased() local function listenersDecreased()
local count = 0 local count = 0
for _ in source.listeners do for _ in pairs(store.listeners[source]) do
count += 1 count += 1
end end
max = math.max(max, count) max = math.max(max, count)
@ -74,11 +63,11 @@ return function()
end end
for index = 1, 500 do for index = 1, 500 do
derive(source, function(value) derive(function()
return string.upper(value) return string.upper(source())
end) end)
if index % 50 == 0 then if index % 50 == 0 then
-- derived atoms check if they are collected when they are updated -- derived atoms should be collected when memory is stressed
source(source() .. "x") source(source() .. "x")
task.wait() task.wait()
end end
@ -89,14 +78,4 @@ return function()
error("None of the derived atoms were collected") error("None of the derived atoms were collected")
end) end)
it("unmounts from parent atom", function()
local source = atom(1)
local derived = derive(source, function(value)
return value
end)
expect((next(source.listeners))).to.be.ok()
derived:unmount()
expect((next(source.listeners))).to.never.be.ok()
end)
end end

View file

@ -0,0 +1,30 @@
local atom = require(script.Parent.Parent.atom)
local effect = require(script.Parent.Parent.effect)
return function()
it("runs on state change", function()
local a = atom(0)
local b = atom(0)
local current
local unsubscribe = effect(function()
current = a() + b()
end)
expect(current).to.equal(0)
for index = 1, 3 do
a(index)
expect(current).to.equal(index)
end
for index = 1, 3 do
b(index)
expect(current).to.equal(a() + index)
end
unsubscribe()
a(4)
expect(current).to.equal(6)
end)
end

View file

@ -1,4 +1,4 @@
local reference = require(script.Parent.Parent.utils.reference) local store = require(script.Parent.Parent.store)
return function() return function()
local previous = _G.__DEV__ local previous = _G.__DEV__
@ -12,6 +12,6 @@ return function()
end) end)
afterEach(function() afterEach(function()
reference.clear() table.clear(store.listeners :: any)
end) end)
end end

View file

@ -1,6 +1,7 @@
local atom = require(script.Parent.Parent.atom) local atom = require(script.Parent.Parent.atom)
local mapAtom = require(script.Parent.Parent.mapAtom) local mapAtom = require(script.Parent.Parent.mapAtom)
local observe = require(script.Parent.Parent.observe) local observe = require(script.Parent.Parent.observe)
local store = require(script.Parent.Parent.store)
return function() return function()
it("returns mapped atoms", function() it("returns mapped atoms", function()
@ -10,7 +11,7 @@ return function()
end) end)
local updates = 0 local updates = 0
mappedAtom.listeners[function() store.listeners[mappedAtom][function()
updates += 1 updates += 1
end] = true end] = true
@ -67,7 +68,7 @@ return function()
local added = 0 local added = 0
local deleted = 0 local deleted = 0
local _, flush = observe(mappedAtom, function(item, key) observe(mappedAtom, function(item, key)
expect(item).to.equal(itemsAtom()[key]) expect(item).to.equal(itemsAtom()[key])
added += 1 added += 1
@ -81,25 +82,13 @@ return function()
expect(deleted).to.equal(0) expect(deleted).to.equal(0)
itemsAtom({ itemsAtom()[1], itemsAtom()[3] }) itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
flush()
expect(added).to.equal(3) expect(added).to.equal(3)
expect(deleted).to.equal(1) expect(deleted).to.equal(1)
itemsAtom({}) itemsAtom({})
flush()
expect(added).to.equal(3) expect(added).to.equal(3)
expect(deleted).to.equal(3) expect(deleted).to.equal(3)
end) 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 end

View file

@ -6,7 +6,7 @@ return function()
local itemsAtom = atom({ "a", "b", "c" }) local itemsAtom = atom({ "a", "b", "c" })
local state = {} local state = {}
local cleanup, flush = observe(itemsAtom, function(item, key) local cleanup = observe(itemsAtom, function(item, key)
state[key] = item state[key] = item
return function() return function()
state[key] = nil state[key] = nil
@ -14,14 +14,13 @@ return function()
end) end)
for key = 1, 3 do for key = 1, 3 do
expect(state[key]).to.equal(itemsAtom:get()[key]) expect(state[key]).to.equal(itemsAtom()[key])
end end
itemsAtom:set({ [4] = "d", [5] = "e", [6] = "f" }) itemsAtom({ [4] = "d", [5] = "e", [6] = "f" })
flush()
for key = 1, 6 do for key = 1, 6 do
expect(state[key]).to.equal(itemsAtom:get()[key]) expect(state[key]).to.equal(itemsAtom()[key])
end end
cleanup() cleanup()

View file

@ -2,7 +2,7 @@ _G.__ROACT_17_MOCK_SCHEDULER__ = true
local Package = script:FindFirstAncestor("__tests__").Parent local Package = script:FindFirstAncestor("__tests__").Parent
local React = require(Package.modules.React) local React = require(Package.modules.React)()
local ReactRoblox = require(Package.modules.ReactRoblox) local ReactRoblox = require(Package.modules.ReactRoblox)
type RenderedHook<Props, Result> = { type RenderedHook<Props, Result> = {

View file

@ -24,11 +24,11 @@ return function()
end) end)
it("memoizes the function", function() it("memoizes the function", function()
local hook = renderHook(function(source) local hook = renderHook(function(props)
return useSetAtom(source) return useSetAtom(props.source)
end, atom(0)) end, { source = atom(0) })
local fn = hook.result local fn = hook.result
hook.rerender(atom(1)) hook.rerender({ source = atom(1) })
expect(hook.result).to.never.equal(fn) expect(hook.result).to.never.equal(fn)
end) end)
end end

View file

@ -6,62 +6,23 @@ return function()
it("runs on state change", function() it("runs on state change", function()
local current, previous local current, previous
local source = atom(0) local source = atom(0)
local unsubscribe, flush = subscribe(source, function(...) local unsubscribe = subscribe(source, function(...)
current, previous = ... current, previous = ...
end) end)
for index = 1, 3 do for index = 1, 3 do
source(index) source(index)
flush()
expect(current).to.equal(index) expect(current).to.equal(index)
expect(previous).to.equal(index - 1) expect(previous).to.equal(index - 1)
end end
unsubscribe() unsubscribe()
source(4) source(4)
flush()
expect(current).to.equal(3) expect(current).to.equal(3)
expect(previous).to.equal(2) expect(previous).to.equal(2)
end) end)
it("batches updates", function()
local current, previous
local source = atom(0)
local _, flush = subscribe(source, function(...)
current, previous = ...
end)
source(1)
source(2)
flush()
expect(current).to.equal(2)
expect(previous).to.equal(0)
end)
it("checks equality", function()
local current, previous
local source = atom(0):memo(function(previous, current)
return current < previous
end)
local _, flush = subscribe(source, function(...)
current, previous = ...
end)
source(1)
flush()
expect(current).to.equal(1)
expect(previous).to.equal(0)
source(0)
flush()
expect(current).to.equal(1)
expect(previous).to.equal(0)
end)
it("prevents garbage collect", function() it("prevents garbage collect", function()
local source = atom(1) local source = atom(1)
local updates = 0 local updates = 0
@ -70,15 +31,13 @@ return function()
-- The idea here is to test whether subscribe() prevents every derived -- The idea here is to test whether subscribe() prevents every derived
-- atom in a chain from being garbage collected. If any are GCed, then -- atom in a chain from being garbage collected. If any are GCed, then
-- the subscription will stop updating and the test will fail. -- the subscription will stop updating and the test will fail.
local derived = derive(function()
return source()
end)
subscribe( subscribe(
derive( derive(function()
derive(source, function(x) return derived() * 2
return x end),
end),
function(x)
return 2 * x
end
),
function() function()
updates += 1 updates += 1
end end

View file

@ -1,126 +1,31 @@
local reference = require(script.Parent.utils.reference) local store = require(script.Parent.store)
local types = require(script.Parent.types)
type Atom<T> = types.Atom<T>
type AtomInternal<T> = { local function atom<T>(state: T): Atom<T>
setForced: (self: AtomInternal<T>, state: T | ((T) -> T)) -> (), local function atom(...)
listeners: { [(T) -> ()]: true }, if select("#", ...) == 0 then
debugTraceback: string, if store.shouldCapture() then
debugLabel: string, store.captured[atom] = true
owners: { [any]: true },
}
export type Atom<T> = (() -> T) & ((state: T | ((T) -> T)) -> ()) & {
get: ((self: Atom<T>) -> T) & (<Result>(self: Atom<T>, selector: (T) -> Result) -> Result),
set: (self: Atom<T>, state: T | ((T) -> T)) -> (),
memo: (self: Atom<T>, areEqual: (previous: T, current: T) -> boolean) -> Atom<T>,
writable: (self: Atom<T>, onSet: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom<T>,
readonly: (self: Atom<T>) -> ReadonlyAtom<T>,
named: (self: Atom<T>, label: string) -> Atom<T>,
unmount: (self: Atom<T>) -> (),
} & AtomInternal<T>
export type ReadonlyAtom<T> = (() -> T) & {
get: ((self: ReadonlyAtom<T>) -> T) & (<Result>(self: ReadonlyAtom<T>, selector: (T) -> Result) -> Result),
memo: (self: ReadonlyAtom<T>, areEqual: (previous: T, current: T) -> boolean) -> ReadonlyAtom<T>,
writable: ((self: Atom<T>) -> Atom<T>)
& ((self: Atom<T>, onSet: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom<T>),
readonly: (self: ReadonlyAtom<T>) -> ReadonlyAtom<T>,
named: (self: ReadonlyAtom<T>, label: string) -> ReadonlyAtom<T>,
unmount: (self: ReadonlyAtom<T>) -> (),
} & AtomInternal<T>
local atomCount = 0
local function atom<State>(state: State): Atom<State>
atomCount += 1
local atom = {
listeners = {},
debugTraceback = debug.traceback(nil, 2),
debugLabel = `atom{atomCount}`,
owners = {},
} :: Atom<State>
local equals: ((State, State) -> boolean)?
local setter: ((State, State) -> State?)?
function atom:get<Result>(selector: ((State) -> Result)?): any
return if selector then selector(state) else state
end
function atom:set(update: State | ((State) -> State))
local nextState = if type(update) == "function" then update(state) else update
if setter then
local result = setter(nextState, state)
if result ~= nil then
nextState = result
else
return
end end
return state
end end
if not (if equals then equals(state, nextState) else state == nextState) then local nextState = if type(...) == "function" then (...)(state) else ...
if state ~= nextState then
state = nextState state = nextState
for listener in table.clone(self.listeners) do for listener in pairs(table.clone(store.listeners[atom])) do
listener(state) listener()
end end
end end
return state
end end
function atom:setForced(update: State | ((State) -> State)) store.listeners[atom] = setmetatable({}, { __mode = "v" })
local nextState = if type(update) == "function" then update(state) else update
if not (if equals then equals(state, nextState) else state == nextState) then
state = nextState
for listener in table.clone(self.listeners) do
listener(state)
end
end
end
function atom:memo(areEqual: (State, State) -> boolean): Atom<State>
equals = areEqual
return self
end
function atom:writable(onSet: ((State, State) -> State?)?): Atom<State>
setter = onSet
return self
end
function atom:readonly(): ReadonlyAtom<State>
function setter(_, _)
if _G.__DEV__ then
error(`Attempt to set read-only atom '{self.debugLabel}' created at: {self.debugTraceback}`, 3)
end
end
return self
end
function atom:named(name: string): Atom<State>
self.debugLabel = name
return self
end
function atom:unmount()
table.clear(self.listeners)
table.clear(self.owners)
reference.release(self)
end
setmetatable(atom :: AtomInternal<State>, {
__type = "Atom",
__call = function(self, ...)
if select("#", ...) > 0 then
return self:set((...))
else
return self:get()
end
end,
})
return atom return atom
end end

View file

@ -1,65 +1,32 @@
local atom = require(script.Parent.atom) local atom = require(script.Parent.atom)
type Atom<T> = atom.Atom<T> local store = require(script.Parent.store)
type ReadonlyAtom<T> = atom.ReadonlyAtom<T> local types = require(script.Parent.types)
local reference = require(script.Parent.utils.reference) type Atom<T> = types.Atom<T>
type ReadonlyAtom<T> = types.ReadonlyAtom<T>
type Derive = local function derive<T>(source: ReadonlyAtom<T>, comparator: ((old: T, new: T) -> boolean)?): ReadonlyAtom<T>
(<A, T>(Atom<A>, (A) -> T) -> ReadonlyAtom<T>) local atoms, state = store.capture(source)
& (<A, B, T>(Atom<A>, Atom<B>, (A, B) -> T) -> ReadonlyAtom<T>) local derived = atom(state)
& (<A, B, C, T>(Atom<A>, Atom<B>, Atom<C>, (A, B, C) -> T) -> ReadonlyAtom<T>) local derivedRef = setmetatable({ current = derived }, { __mode = "v" })
& (<A, B, C, D, T>(Atom<A>, Atom<B>, Atom<C>, Atom<D>, (A, B, C, D) -> T) -> ReadonlyAtom<T>)
& (<A, B, C, D, E, T>(Atom<A>, Atom<B>, Atom<C>, Atom<D>, Atom<E>, (A, B, C, D, E) -> T) -> ReadonlyAtom<T>)
& (<T>(...(Atom<any> | ((...any) -> T))) -> ReadonlyAtom<T>)
local function derive<Result>(...): ReadonlyAtom<Result> local function listener()
local atoms: { Atom<unknown> } = { ... } if not derivedRef.current then
local combiner: (...unknown) -> Result = table.remove(atoms) :: any return
local size = 0 end
local dependencies: { unknown } = {}
local listeners: { [() -> ()]: boolean } = {}
for index, atom in atoms do local nextState = source()
dependencies[index] = atom:get()
size += 1
end
local derivedAtom = atom(combiner(table.unpack(dependencies, 1, size))):readonly() if state ~= nextState and not (comparator and comparator(state, nextState)) then
local derivedAtomUnmount = derivedAtom.unmount state = nextState
local getDerivedAtom = reference.weak(derivedAtom) derivedRef.current(nextState)
function derivedAtom:unmount()
derivedAtomUnmount(self)
for listener in listeners do
for _, atom in atoms do
atom.listeners[listener] = nil
end
end end
end end
for index, atom in atoms do for atom in atoms do
local function listener(state: unknown) store.listeners[atom][listener] = derived
-- Remove the listener if the derived atom has been garbage collected,
-- typically because no subscriptions to it exist.
local derivedAtom = getDerivedAtom()
if derivedAtom then
dependencies[index] = state
derivedAtom:setForced(combiner(table.unpack(dependencies, 1, size)))
else
atom.listeners[listener] = nil
end
end
-- Tie the lifetime of the derived atom to the lifetime of its dependencies
-- without creating a strong reference cycle. As long as an atom is in use,
-- its derived atoms will be in use as well.
derivedAtom.owners[atom] = true
atom.listeners[listener] = true
listeners[listener] = true
end end
return derivedAtom return derived
end end
return derive :: Derive return derive

23
src/effect.luau Normal file
View file

@ -0,0 +1,23 @@
local store = require(script.Parent.store)
local types = require(script.Parent.types)
type ReadonlyAtom<T> = types.ReadonlyAtom<T>
local function effect(source: ReadonlyAtom<nil>): () -> ()
local atoms = store.capture(source)
local function listener()
source()
end
for atom in atoms do
store.listeners[atom][listener] = true
end
return function()
for atom in atoms do
store.listeners[atom][listener] = nil
end
end
end
return effect

73
src/index.d.ts vendored
View file

@ -2,91 +2,74 @@ export = Charm;
export as namespace Charm; export as namespace Charm;
declare namespace Charm { declare namespace Charm {
interface InternalAtom<State> { interface Atom<T> extends ReadonlyAtom<T> {
/** @hidden */ readonly __nominal: unique symbol;
readonly listeners: Set<(value: State) => void>; (state: T | ((state: T) => T)): T;
/** @hidden */
readonly debugTraceback: string;
/** @hidden */
readonly debugLabel: string;
/** @hidden */
setForced(state: State | ((current: State) => State)): void;
} }
interface BaseAtom<State> extends InternalAtom<State> { interface ReadonlyAtom<T> {
get(): State; (): T;
get<Result>(selector: (state: State) => Result): Result;
set(state: State | ((current: State) => State)): void;
memo(areEqual: (previous: State, current: State) => boolean): this;
writable(onSet?: (update: State, previous: State) => State | void): Atom<State>;
readonly(): ReadonlyAtom<State>;
named(label: string): this;
unmount(): void;
} }
interface Atom<State> extends BaseAtom<State> { type AtomArrayToStates<Atoms extends ReadonlyAtom<any>[]> = {
(): State; [Key in keyof Atoms]: Atoms[Key] extends ReadonlyAtom<infer State> ? State : never;
(state: State | ((previous: State) => State)): void;
}
interface ReadonlyAtom<State> extends BaseAtom<State> {
(): State;
set: never;
}
type AtomArrayToStates<Atoms extends Atom<any>[]> = {
[Key in keyof Atoms]: Atoms[Key] extends Atom<infer State> ? State : never;
}; };
function atom<State>(state: State): Atom<State>; function atom<State>(state: State): Atom<State>;
function isAtom(value: unknown): value is Atom<any>; function isAtom(value: unknown): value is Atom<any>;
function derive<Atoms extends Atom<any>[], Result>( function derive<State>(
...args: [...atoms: Atoms, combiner: (...atoms: AtomArrayToStates<Atoms>) => Result] atom: ReadonlyAtom<State>,
): ReadonlyAtom<Result>; comparator?: (previous: State, current: State) => boolean,
): ReadonlyAtom<State>;
function subscribe<State>(atom: Atom<State>, callback: (state: State) => void): () => void; function subscribe<State>(
atom: ReadonlyAtom<State>,
callback: (state: State, previousState: State) => void,
): () => void;
function effect(callback: () => void): () => void;
function observe<K, V>( function observe<K, V>(
atom: Atom<Map<K, V> | ReadonlyMap<K, V>>, atom: ReadonlyAtom<Map<K, V> | ReadonlyMap<K, V>>,
factory: (value: V, key: K) => (() => void) | void, factory: (value: V, key: K) => (() => void) | void,
): () => void; ): () => void;
function observe(atom: Atom<any>, factory: (value: unknown, key: unknown) => (() => void) | void): () => void; function observe(atom: ReadonlyAtom<any>, factory: (value: unknown, key: unknown) => (() => void) | void): () => void;
function mapAtom<V0, K1, V1>( function mapAtom<V0, K1, V1>(
atom: Atom<readonly V0[]> | Atom<V0[]>, atom: ReadonlyAtom<readonly V0[]> | ReadonlyAtom<V0[]>,
mapper: (value: V0, index: number) => LuaTuple<[V1 | undefined, K1]>, mapper: (value: V0, index: number) => LuaTuple<[V1 | undefined, K1]>,
): ReadonlyAtom<ReadonlyMap<K1, V1>>; ): ReadonlyAtom<ReadonlyMap<K1, V1>>;
function mapAtom<V0, V1>( function mapAtom<V0, V1>(
atom: Atom<readonly V0[]> | Atom<V0[]>, atom: ReadonlyAtom<readonly V0[]> | ReadonlyAtom<V0[]>,
mapper: (value: V0, index: number) => V1, mapper: (value: V0, index: number) => V1,
): ReadonlyAtom<readonly V1[]>; ): ReadonlyAtom<readonly V1[]>;
function mapAtom<K0, V0, K1 = K0, V1 = V0>( function mapAtom<K0, V0, K1 = K0, V1 = V0>(
atom: Atom<ReadonlyMap<K0, V0>> | Atom<Map<K0, V0>>, atom: ReadonlyAtom<ReadonlyMap<K0, V0>> | ReadonlyAtom<Map<K0, V0>>,
mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1, mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1,
): ReadonlyAtom<ReadonlyMap<K1, V1>>; ): ReadonlyAtom<ReadonlyMap<K1, V1>>;
function mapAtom<K0 extends string | number | symbol, V0, K1, V1>( function mapAtom<K0 extends string | number | symbol, V0, K1, V1>(
atom: Atom<{ readonly [K in K0]: V0 }>, atom: ReadonlyAtom<{ readonly [K in K0]: V0 }>,
mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1, mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1,
): ReadonlyAtom<ReadonlyMap<K1, V1>>; ): ReadonlyAtom<ReadonlyMap<K1, V1>>;
function useAtomState<State>(atom: Atom<State>): State; function useAtomState<State>(atom: ReadonlyAtom<State>): State;
function useAtomState<State, Result>(atom: Atom<State>, selector: (state: State) => Result): Result; function useAtomState<State, Result>(atom: ReadonlyAtom<State>, selector: (state: State) => Result): Result;
function useSetAtom<State>(atom: Atom<State>): (state: State | ((previous: State) => State)) => void; function useSetAtom<State>(atom: ReadonlyAtom<State>): (state: State | ((previous: State) => State)) => void;
function useAtom<State>( function useAtom<State>(
atom: Atom<State>, atom: ReadonlyAtom<State>,
): LuaTuple<[state: State, setState: (state: State | ((previous: State) => State)) => void]>; ): LuaTuple<[state: State, setState: (state: State | ((previous: State) => State)) => void]>;
function useAtom<State, Result>( function useAtom<State, Result>(
atom: Atom<State>, atom: ReadonlyAtom<State>,
selector: (state: State) => Result, selector: (state: State) => Result,
): LuaTuple<[state: Result, setState: (state: Result | ((previous: Result) => Result)) => void]>; ): LuaTuple<[state: Result, setState: (state: Result | ((previous: Result) => Result)) => void]>;

View file

@ -2,6 +2,7 @@ _G[script.modules.Promise] = _G[script]
local atom = require(script.atom) local atom = require(script.atom)
local derive = require(script.derive) local derive = require(script.derive)
local effect = require(script.effect)
local isAtom = require(script.isAtom) local isAtom = require(script.isAtom)
local mapAtom = require(script.mapAtom) local mapAtom = require(script.mapAtom)
local observe = require(script.observe) local observe = require(script.observe)
@ -10,16 +11,18 @@ local useAtomState = require(script.react.useAtomState)
local useSetAtom = require(script.react.useSetAtom) local useSetAtom = require(script.react.useSetAtom)
local subscribe = require(script.subscribe) local subscribe = require(script.subscribe)
local sync = require(script.sync) local sync = require(script.sync)
local types = require(script.types)
export type Atom<State> = atom.Atom<State> export type Atom<State> = types.Atom<State>
export type ReadonlyAtom<State> = atom.ReadonlyAtom<State> export type ReadonlyAtom<State> = types.ReadonlyAtom<State>
return { return {
atom = atom, atom = atom,
derive = derive, derive = derive,
effect = effect,
isAtom = isAtom, isAtom = isAtom,
observe = observe,
mapAtom = mapAtom, mapAtom = mapAtom,
observe = observe,
subscribe = subscribe, subscribe = subscribe,
sync = sync, sync = sync,
useAtom = useAtom, useAtom = useAtom,

View file

@ -1,5 +1,7 @@
local store = require(script.Parent.store)
local function isAtom(value: any) local function isAtom(value: any)
return type(value) == "table" and getmetatable(value) and getmetatable(value).__type == "Atom" or false return type(value) == "function" and store.listeners[value] :: any ~= nil
end end
return isAtom return isAtom

View file

@ -1,23 +1,21 @@
local atom = require(script.Parent.atom) local atom = require(script.Parent.atom)
type Atom<T> = atom.Atom<T> local subscribe = require(script.Parent.subscribe)
type ReadonlyAtom<T> = atom.ReadonlyAtom<T> local types = require(script.Parent.types)
local reference = require(script.Parent.utils.reference) type Atom<T> = types.Atom<T>
type ReadonlyAtom<T> = types.ReadonlyAtom<T>
local function mapAtom<K0, V0, K1, V1>(itemsAtom: Atom<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): ReadonlyAtom<{ [K1]: V1 }> local function mapAtom<K0, V0, K1, V1>(itemsAtom: Atom<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): ReadonlyAtom<{ [K1]: V1 }>
local mappedAtom: ReadonlyAtom<{ [K1]: V1 }> = atom({}):readonly() local mappedAtom = atom({})
local mappedAtomUnmount = mappedAtom.unmount local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" })
local getMappedAtom = reference.weak(mappedAtom)
local previousMappedItems: { [K1]: V1 } = {} local previousMappedItems: { [K1]: V1 } = {}
local unsubscribe
local function listener(items) local function listener(items: { [K0]: V0 })
local mappedAtom = getMappedAtom() if not mappedAtomRef.current then
return unsubscribe()
if not mappedAtom then
itemsAtom.listeners[listener] = nil
return
end end
local mappedItems = table.clone(mappedAtom:get()) local mappedItems = table.clone(mappedAtomRef.current())
local mappedKeys = {} local mappedKeys = {}
for key, item in items do for key, item in items do
@ -39,16 +37,11 @@ local function mapAtom<K0, V0, K1, V1>(itemsAtom: Atom<{ [K0]: V0 }>, mapper: (V
end end
previousMappedItems = mappedItems previousMappedItems = mappedItems
mappedAtom:setForced(mappedItems) mappedAtom(mappedItems)
end end
function mappedAtom:unmount() unsubscribe = subscribe(itemsAtom, listener)
mappedAtomUnmount(self) listener(itemsAtom())
itemsAtom.listeners[listener] = nil
end
itemsAtom.listeners[listener] = true
listener(itemsAtom:get())
return mappedAtom return mappedAtom
end end

View file

@ -5,4 +5,11 @@ local Packages = if Package.Parent:FindFirstChild("React")
type React = typeof(require(Package.Parent.React)) type React = typeof(require(Package.Parent.React))
return (if Packages then require(Packages.React) else nil) :: React local React: React
return function()
if not React then
React = Packages and require(Packages.React) :: React
end
return React
end

View file

@ -1,11 +1,13 @@
local atom = require(script.Parent.atom) local effect = require(script.Parent.effect)
type Atom<T> = atom.Atom<T> local types = require(script.Parent.types)
local subscribe = require(script.Parent.subscribe) type ReadonlyAtom<T> = types.ReadonlyAtom<T>
local function observe<Item, Key>(atom: Atom<{ [Key]: Item }>, factory: (value: Item, key: Key) -> () -> ()): (() -> (), () -> ()) local function observe<V, K>(source: ReadonlyAtom<{ [K]: V }>, factory: (value: V, key: K) -> () -> ()): () -> ()
local connections: { [Key]: () -> () } = {} local connections: { [K]: () -> () } = {}
local unsubscribe = effect(function()
local state = source()
local function update(state: { [Key]: Item })
for key, disconnect in connections do for key, disconnect in connections do
if state[key] == nil then if state[key] == nil then
connections[key] = nil connections[key] = nil
@ -18,9 +20,7 @@ local function observe<Item, Key>(atom: Atom<{ [Key]: Item }>, factory: (value:
connections[key] = factory(value, key) connections[key] = factory(value, key)
end end
end end
end end)
local unsubscribe, flush = subscribe(atom, update)
local function cleanup() local function cleanup()
unsubscribe() unsubscribe()
@ -29,9 +29,7 @@ local function observe<Item, Key>(atom: Atom<{ [Key]: Item }>, factory: (value:
end end
end end
update(atom:get()) return cleanup
return cleanup, flush
end end
return observe return observe

View file

@ -1,5 +1,5 @@
local atom = require(script.Parent.Parent.atom) local types = require(script.Parent.Parent.types)
type Atom<T> = atom.Atom<T> type Atom<T> = types.Atom<T>
local useAtomState = require(script.Parent.useAtomState) local useAtomState = require(script.Parent.useAtomState)
local useSetAtom = require(script.Parent.useSetAtom) local useSetAtom = require(script.Parent.useSetAtom)

View file

@ -1,7 +1,8 @@
local React = require(script.Parent.Parent.modules.React) local requireReact = require(script.Parent.Parent.modules.React)
local subscribe = require(script.Parent.Parent.subscribe)
local atom = require(script.Parent.Parent.atom) local types = require(script.Parent.Parent.types)
type Atom<T> = atom.Atom<T> type Atom<T> = types.Atom<T>
type UseAtomState = type UseAtomState =
(<State>(atom: Atom<State>) -> State) (<State>(atom: Atom<State>) -> State)
@ -9,24 +10,20 @@ type UseAtomState =
& (<State, Result>(atom: Atom<State>, selector: (State) -> Result) -> Result) & (<State, Result>(atom: Atom<State>, selector: (State) -> Result) -> Result)
local function useAtomState<State>(atom: Atom<State>, selector: ((State) -> State)?): State local function useAtomState<State>(atom: Atom<State>, selector: ((State) -> State)?): State
local React = requireReact()
local state, setState = React.useState(function() local state, setState = React.useState(function()
return atom:get(selector :: (State) -> State) return if selector then atom(selector) else atom()
end) end)
local latestSelector = React.useRef(selector) local latestSelector = React.useRef(selector)
latestSelector.current = selector latestSelector.current = selector
React.useEffect(function() React.useEffect(function()
local function listener(state: State) return subscribe(atom, function(state)
local selector = latestSelector.current local selector = latestSelector.current
setState(if selector then selector(state) else state) setState(if selector then selector(state) else state)
end end)
atom.listeners[listener] = true
return function()
atom.listeners[listener] = nil
end
end, { atom }) end, { atom })
return state return state

View file

@ -1,13 +1,13 @@
local React = require(script.Parent.Parent.modules.React) local requireReact = require(script.Parent.Parent.modules.React)
local atom = require(script.Parent.Parent.atom) local types = require(script.Parent.Parent.types)
type Atom<T> = atom.Atom<T> type Atom<T> = types.Atom<T>
export type SetState<T> = (T | ((T) -> T)) -> () export type SetState<T> = (T | ((T) -> T)) -> ()
local function useSetAtom<State>(atom: Atom<State>): SetState<State> local function useSetAtom<State>(atom: Atom<State>): SetState<State>
return React.useCallback(function(value) return requireReact().useCallback(function(value)
atom:set(value) atom(value)
end, { atom }) end, { atom })
end end

38
src/store.luau Normal file
View file

@ -0,0 +1,38 @@
local types = require(script.Parent.types)
type Atom<T> = types.Atom<T>
type ReadonlyAtom<T> = types.ReadonlyAtom<T>
type WeakMap<K, V> = types.WeakMap<K, V>
local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" })
local captured: { [Atom<any>]: true } = {}
local capturing = false
local function capture<T>(source: ReadonlyAtom<T>): ({ [Atom<any>]: true }, T)
if listeners[source] then
return { [source] = true }, source()
end
capturing = true
local success, result = pcall(source)
capturing = false
local dependencies = table.clone(captured)
table.clear(captured)
if not success then
warn("An error occurred while calling an atom:", result)
end
return dependencies, result
end
local function shouldCapture()
return capturing
end
return {
listeners = listeners,
captured = captured,
capture = capture,
shouldCapture = shouldCapture,
}

View file

@ -1,45 +1,25 @@
local atom = require(script.Parent.atom) local store = require(script.Parent.store)
type Atom<T> = atom.Atom<T> local types = require(script.Parent.types)
local reference = require(script.Parent.utils.reference) type ReadonlyAtom<T> = types.ReadonlyAtom<T>
local function subscribe<State>(atom: Atom<State>, callback: (state: State, previous: State) -> ()): (() -> (), () -> ()) local function subscribe<T>(source: ReadonlyAtom<T>, callback: (state: T, previous: T) -> ()): () -> ()
local pending: thread? = nil local atoms, state = store.capture(source)
local state = atom:get()
local function emit() local function listener()
local lastState = state local lastState = state
state = atom:get() state = source()
callback(state, lastState) callback(state, lastState)
end end
local function update() for atom in atoms do
pending = pending or task.defer(function() store.listeners[atom][listener] = true
pending = nil
emit()
end)
end end
local function flush() return function()
if pending then for atom in atoms do
pending = task.cancel(pending) store.listeners[atom][listener] = nil
emit()
end end
end end
local function unsubscribe()
reference.release(atom)
atom.listeners[update] = nil
if pending then
pending = task.cancel(pending)
end
end
-- Ensure the atom is held in memory while the subscription is active
reference.hold(atom)
atom.listeners[update] = true
return unsubscribe, flush
end end
return subscribe return subscribe

View file

@ -1,14 +1,15 @@
local atom = require(script.Parent.Parent.atom) local atomTypes = require(script.Parent.Parent.types)
type Atom<T> = atom.Atom<T> type Atom<T> = atomTypes.Atom<T>
local patch = require(script.Parent.patch) local patch = require(script.Parent.patch)
local types = require(script.Parent.types) local types = require(script.Parent.types)
type SyncPayload = types.SyncPayload
type ClientSyncOptions = { type ClientSyncOptions = {
atoms: { [string]: Atom<any> }, atoms: { [string]: Atom<any> },
} }
type ClientSyncer = { type ClientSyncer = {
sync: (self: ClientSyncer, payload: types.SyncPayload) -> (), sync: (self: ClientSyncer, payload: SyncPayload) -> (),
} }
local function client(options: ClientSyncOptions): ClientSyncer local function client(options: ClientSyncOptions): ClientSyncer
@ -18,9 +19,9 @@ local function client(options: ClientSyncOptions): ClientSyncer
function self:sync(payload) function self:sync(payload)
for key, state in payload.data do for key, state in payload.data do
if payload.type == "patch" then if payload.type == "patch" then
atoms[key]:set(patch.apply(atoms[key]:get(), state)) atoms[key](patch.apply(atoms[key](), state))
else else
atoms[key]:set(state) atoms[key](state)
end end
end end
end end

View file

@ -3,17 +3,17 @@ local validate = require(script.Parent.validate)
local NONE = { __none = "__none" } local NONE = { __none = "__none" }
local function diff(previousState: { [any]: any }, nextState: { [any]: any }) local function diff(previousState: { [any]: any }, nextState: { [any]: any })
local patch = table.clone(nextState) local patches = table.clone(nextState)
for key, previous in previousState do for key, previous in previousState do
local next = nextState[key] local next = nextState[key]
if previous == next then if previous == next then
patch[key] = nil patches[key] = nil
elseif next == nil then elseif next == nil then
patch[key] = NONE patches[key] = NONE
elseif type(previous) == "table" and type(next) == "table" then elseif type(previous) == "table" and type(next) == "table" then
patch[key] = diff(previous, next) patches[key] = diff(previous, next)
end end
end end
@ -23,7 +23,9 @@ local function diff(previousState: { [any]: any }, nextState: { [any]: any })
validate(value, key) validate(value, key)
end end
for key, value in nextState do for key, value in nextState do
validate(value, key) if previousState[key] ~= value then
validate(value, key)
end
end end
end) end)
@ -32,13 +34,13 @@ local function diff(previousState: { [any]: any }, nextState: { [any]: any })
end end
end end
return patch return patches
end end
local function apply(state: { [any]: any }, patchState: { [any]: any }) local function apply(state: { [any]: any }, patches: { [any]: any })
local nextState = table.clone(state) local nextState = table.clone(state)
for key, patch in patchState do for key, patch in patches do
local current = nextState[key] local current = nextState[key]
if type(patch) == "table" and patch.__none == "__none" then if type(patch) == "table" and patch.__none == "__none" then

View file

@ -1,10 +1,11 @@
local Players = game:GetService("Players") local Players = game:GetService("Players")
local atom = require(script.Parent.Parent.atom) local atomTypes = require(script.Parent.Parent.types)
type Atom<T> = atom.Atom<T> type Atom<T> = atomTypes.Atom<T>
local setInterval = require(script.Parent.Parent.utils.setInterval) local setInterval = require(script.Parent.Parent.utils.setInterval)
local patch = require(script.Parent.patch) local patch = require(script.Parent.patch)
local types = require(script.Parent.types) local types = require(script.Parent.types)
type SyncPayload = types.SyncPayload
type ServerSyncOptions = { type ServerSyncOptions = {
atoms: { [string]: Atom<any> }, atoms: { [string]: Atom<any> },
@ -12,7 +13,7 @@ type ServerSyncOptions = {
} }
type ServerSyncer = { type ServerSyncer = {
onSync: (self: ServerSyncer, callback: (player: Player, payload: types.SyncPayload) -> ()) -> () -> (), onSync: (self: ServerSyncer, callback: (player: Player, payload: SyncPayload) -> ()) -> () -> (),
flush: (self: ServerSyncer, player: Player?) -> (), flush: (self: ServerSyncer, player: Player?) -> (),
hydrate: (self: ServerSyncer, player: Player) -> (), hydrate: (self: ServerSyncer, player: Player) -> (),
} }
@ -21,13 +22,13 @@ local function server(options: ServerSyncOptions): ServerSyncer
local atoms = options.atoms local atoms = options.atoms
local interval = options.interval or 0 local interval = options.interval or 0
local self = {} :: ServerSyncer local self = {} :: ServerSyncer
local sync: (player: Player, payload: types.SyncPayload) -> () local sync: (player: Player, payload: SyncPayload) -> ()
local state: {} local state: {}
local function getState() local function getState()
local state = {} local state = {}
for key, atom in atoms do for key, atom in atoms do
state[key] = atom:get() state[key] = atom()
end end
return state return state
end end
@ -47,7 +48,7 @@ local function server(options: ServerSyncOptions): ServerSyncer
state = current state = current
if current ~= previous then if current ~= previous then
local payload: types.SyncPayload = { local payload: SyncPayload = {
type = "patch", type = "patch",
data = patch.diff(previous, current), data = patch.diff(previous, current),
} }

5
src/types.luau Normal file
View file

@ -0,0 +1,5 @@
export type Atom<T> = (() -> T) & ((T | (T) -> T) -> T)
export type ReadonlyAtom<T> = () -> T
export type WeakMap<K, V> = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" }))
return nil