mirror of
https://github.com/littensy/charm.git
synced 2026-10-21 03:36:11 +00:00
feat: initial commit
This commit is contained in:
commit
18723c1377
50 changed files with 3238 additions and 0 deletions
85
src/__tests__/atom.spec.luau
Normal file
85
src/__tests__/atom.spec.luau
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
|
||||
return function()
|
||||
it("updates state", function()
|
||||
local source = atom(1)
|
||||
source(2)
|
||||
expect(source()).to.equal(2)
|
||||
source(function(x)
|
||||
return x + 1
|
||||
end)
|
||||
expect(source()).to.equal(3)
|
||||
end)
|
||||
|
||||
it("calls listeners", function()
|
||||
local source = atom(1)
|
||||
local value
|
||||
local function listener(state)
|
||||
value = state
|
||||
end
|
||||
source.listeners[listener] = true
|
||||
source(2)
|
||||
expect(value).to.equal(2)
|
||||
end)
|
||||
|
||||
it("skips pending listeners", function()
|
||||
local source = atom(1)
|
||||
local outer = 0
|
||||
local inner = 0
|
||||
local function listener()
|
||||
outer += 1
|
||||
local function listener()
|
||||
inner += 1
|
||||
end
|
||||
source.listeners[listener] = true
|
||||
end
|
||||
source.listeners[listener] = true
|
||||
source(2)
|
||||
expect(outer).to.equal(1)
|
||||
expect(inner).to.equal(0)
|
||||
source(3)
|
||||
expect(outer).to.equal(2)
|
||||
expect(inner).to.equal(1)
|
||||
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
|
||||
92
src/__tests__/derive.spec.luau
Normal file
92
src/__tests__/derive.spec.luau
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local derive = require(script.Parent.Parent.derive)
|
||||
|
||||
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()
|
||||
local calls = 0
|
||||
local source = atom(1)
|
||||
local double = derive(source, function(value: number)
|
||||
calls += 1
|
||||
return value * 2
|
||||
end)
|
||||
expect(calls).to.equal(1)
|
||||
source(1)
|
||||
expect(calls).to.equal(1)
|
||||
source(2)
|
||||
expect(calls).to.equal(2)
|
||||
double()
|
||||
expect(calls).to.equal(2)
|
||||
end)
|
||||
|
||||
it("can be a dependency", function()
|
||||
local source = atom(1)
|
||||
local double = derive(source, function(value: number)
|
||||
return value * 2
|
||||
end)
|
||||
local quadruple = derive(double, function(value: number)
|
||||
return value * 2
|
||||
end)
|
||||
expect(quadruple()).to.equal(4)
|
||||
source(2)
|
||||
expect(quadruple()).to.equal(8)
|
||||
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()
|
||||
-- large values coerce the garbage collector to run
|
||||
local source = atom(string.rep("x", 1e5))
|
||||
local max = 0
|
||||
|
||||
local function listenersDecreased()
|
||||
local count = 0
|
||||
for _ in source.listeners do
|
||||
count += 1
|
||||
end
|
||||
max = math.max(max, count)
|
||||
return count < max
|
||||
end
|
||||
|
||||
for index = 1, 500 do
|
||||
derive(source, function(value)
|
||||
return string.upper(value)
|
||||
end)
|
||||
if index % 50 == 0 then
|
||||
-- derived atoms check if they are collected when they are updated
|
||||
source(source() .. "x")
|
||||
task.wait()
|
||||
end
|
||||
if listenersDecreased() then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
error("None of the derived atoms were collected")
|
||||
end)
|
||||
end
|
||||
7
src/__tests__/init.spec.luau
Normal file
7
src/__tests__/init.spec.luau
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
local reference = require(script.Parent.Parent.utils.reference)
|
||||
|
||||
return function()
|
||||
afterEach(function()
|
||||
reference.clear()
|
||||
end)
|
||||
end
|
||||
33
src/__tests__/observe.spec.luau
Normal file
33
src/__tests__/observe.spec.luau
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local observe = require(script.Parent.Parent.observe)
|
||||
|
||||
return function()
|
||||
it("observes lifetime", function()
|
||||
local itemsAtom = atom({ "a", "b", "c" })
|
||||
local state = {}
|
||||
|
||||
local cleanup, flush = observe(itemsAtom, function(item, key)
|
||||
state[key] = item
|
||||
return function()
|
||||
state[key] = nil
|
||||
end
|
||||
end)
|
||||
|
||||
for key = 1, 3 do
|
||||
expect(state[key]).to.equal(itemsAtom:get()[key])
|
||||
end
|
||||
|
||||
itemsAtom:set({ [4] = "d", [5] = "e", [6] = "f" })
|
||||
flush()
|
||||
|
||||
for key = 1, 6 do
|
||||
expect(state[key]).to.equal(itemsAtom:get()[key])
|
||||
end
|
||||
|
||||
cleanup()
|
||||
|
||||
for key = 1, 6 do
|
||||
expect(state[key]).to.equal(nil)
|
||||
end
|
||||
end)
|
||||
end
|
||||
42
src/__tests__/react/helpers/renderHook.luau
Normal file
42
src/__tests__/react/helpers/renderHook.luau
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
_G.__ROACT_17_MOCK_SCHEDULER__ = true
|
||||
|
||||
local Package = script:FindFirstAncestor("__tests__").Parent
|
||||
|
||||
local React = require(Package.modules.React)
|
||||
local ReactRoblox = require(Package.modules.ReactRoblox)
|
||||
|
||||
type RenderedHook<Props, Result> = {
|
||||
result: Result,
|
||||
rerender: (Props?) -> (),
|
||||
unmount: () -> (),
|
||||
}
|
||||
|
||||
local function renderHook<Props, Result>(callback: (Props) -> Result, props: Props?): RenderedHook<Props, Result>
|
||||
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
|
||||
local hook = {}
|
||||
|
||||
local function Component(props)
|
||||
hook.result = callback(props)
|
||||
end
|
||||
|
||||
function hook.rerender(newProps)
|
||||
props = newProps or props
|
||||
ReactRoblox.act(function()
|
||||
root:render(React.createElement(Component, props))
|
||||
end)
|
||||
end
|
||||
|
||||
function hook.unmount()
|
||||
ReactRoblox.act(function()
|
||||
root:unmount()
|
||||
end)
|
||||
end
|
||||
|
||||
ReactRoblox.act(function()
|
||||
hook.rerender(props)
|
||||
end)
|
||||
|
||||
return hook
|
||||
end
|
||||
|
||||
return renderHook
|
||||
27
src/__tests__/react/useAtomState.spec.luau
Normal file
27
src/__tests__/react/useAtomState.spec.luau
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
local atom = require(script.Parent.Parent.Parent.atom)
|
||||
local useAtomState = require(script.Parent.Parent.Parent.react.useAtomState)
|
||||
local renderHook = require(script.Parent.helpers.renderHook)
|
||||
|
||||
return function()
|
||||
it("tracks atom state", function()
|
||||
local source = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useAtomState(source)
|
||||
end)
|
||||
expect(hook.result).to.equal(0)
|
||||
source(1)
|
||||
expect(hook.result).to.equal(1)
|
||||
end)
|
||||
|
||||
it("receives a selector", function()
|
||||
local source = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useAtomState(source, function(value)
|
||||
return 2 * value
|
||||
end)
|
||||
end)
|
||||
expect(hook.result).to.equal(0)
|
||||
source(1)
|
||||
expect(hook.result).to.equal(2)
|
||||
end)
|
||||
end
|
||||
34
src/__tests__/react/useSetAtom.spec.luau
Normal file
34
src/__tests__/react/useSetAtom.spec.luau
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
local atom = require(script.Parent.Parent.Parent.atom)
|
||||
local useSetAtom = require(script.Parent.Parent.Parent.react.useSetAtom)
|
||||
local renderHook = require(script.Parent.helpers.renderHook)
|
||||
|
||||
return function()
|
||||
it("sets atom state", function()
|
||||
local source = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useSetAtom(source)
|
||||
end)
|
||||
expect(hook.result).to.be.a("function")
|
||||
hook.result(1)
|
||||
expect(source()).to.equal(1)
|
||||
end)
|
||||
|
||||
it("returns a stable function", function()
|
||||
local source = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useSetAtom(source)
|
||||
end)
|
||||
local fn = hook.result
|
||||
hook.rerender()
|
||||
expect(hook.result).to.equal(fn)
|
||||
end)
|
||||
|
||||
it("memoizes the function", function()
|
||||
local hook = renderHook(function(source)
|
||||
return useSetAtom(source)
|
||||
end, atom(0))
|
||||
local fn = hook.result
|
||||
hook.rerender(atom(1))
|
||||
expect(hook.result).to.never.equal(fn)
|
||||
end)
|
||||
end
|
||||
128
src/__tests__/split.spec.luau
Normal file
128
src/__tests__/split.spec.luau
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
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)
|
||||
end
|
||||
92
src/__tests__/subscribe.spec.luau
Normal file
92
src/__tests__/subscribe.spec.luau
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local derive = require(script.Parent.Parent.derive)
|
||||
local subscribe = require(script.Parent.Parent.subscribe)
|
||||
|
||||
return function()
|
||||
it("runs on state change", function()
|
||||
local current, previous
|
||||
local source = atom(0)
|
||||
local unsubscribe, flush = subscribe(source, function(...)
|
||||
current, previous = ...
|
||||
end)
|
||||
|
||||
for index = 1, 3 do
|
||||
source(index)
|
||||
flush()
|
||||
expect(current).to.equal(index)
|
||||
expect(previous).to.equal(index - 1)
|
||||
end
|
||||
|
||||
unsubscribe()
|
||||
source(4)
|
||||
flush()
|
||||
|
||||
expect(current).to.equal(3)
|
||||
expect(previous).to.equal(2)
|
||||
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()
|
||||
local source = atom(1)
|
||||
local updates = 0
|
||||
|
||||
for _ = 1, 1000 do
|
||||
-- The idea here is to test whether subscribe() prevents every derived
|
||||
-- atom in a chain from being garbage collected. If any are GCed, then
|
||||
-- the subscription will stop updating and the test will fail.
|
||||
subscribe(
|
||||
derive(
|
||||
derive(source, function(x)
|
||||
return x
|
||||
end),
|
||||
function(x)
|
||||
return 2 * x
|
||||
end
|
||||
),
|
||||
function()
|
||||
updates += 1
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
source(source() + 1)
|
||||
task.wait()
|
||||
expect(updates).to.equal(1000)
|
||||
end)
|
||||
end
|
||||
137
src/atom.luau
Normal file
137
src/atom.luau
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
local reference = require(script.Parent.utils.reference)
|
||||
|
||||
type AtomInternal<T> = {
|
||||
--- @deprecated
|
||||
setForced: (self: AtomInternal<T>, state: T | ((T) -> T)) -> (),
|
||||
--- @deprecated
|
||||
listeners: { [(T) -> ()]: true },
|
||||
--- @deprecated
|
||||
debugTraceback: string,
|
||||
--- @deprecated
|
||||
debugLabel: string,
|
||||
--- @deprecated
|
||||
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>, equalityFn: (previous: T, current: T) -> boolean) -> Atom<T>,
|
||||
writable: (self: Atom<T>, setter: ((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>, equalityFn: (previous: T, current: T) -> boolean) -> ReadonlyAtom<T>,
|
||||
writable: ((self: Atom<T>) -> Atom<T>) & (
|
||||
(self: Atom<T>, setter: ((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 listeners: { [(State) -> ()]: true } = {}
|
||||
local label = `atom{atomCount}`
|
||||
local traceback = debug.traceback(nil, 2)
|
||||
local owners: { [any]: true } = {}
|
||||
|
||||
local atom = {
|
||||
listeners = listeners,
|
||||
debugTraceback = traceback,
|
||||
debugLabel = label,
|
||||
owners = 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
|
||||
return
|
||||
else
|
||||
nextState = result
|
||||
end
|
||||
end
|
||||
|
||||
if not (if equals then equals(state, nextState) else state == nextState) then
|
||||
state = nextState
|
||||
|
||||
for listener in table.clone(listeners) do
|
||||
listener(state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function atom:setForced(update: State | ((State) -> State))
|
||||
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(listeners) do
|
||||
listener(state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function atom:memo(equalityFn: (State, State) -> boolean): Atom<State>
|
||||
equals = equalityFn
|
||||
return self
|
||||
end
|
||||
|
||||
function atom:writable(setterFn: ((State, State) -> State?)?): Atom<State>
|
||||
setter = setterFn
|
||||
return self
|
||||
end
|
||||
|
||||
function atom:readonly(): ReadonlyAtom<State>
|
||||
function setter(_, _)
|
||||
error(`Attempt to set read-only atom '{label}' created at: {traceback}`, 3)
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
function atom:named(name: string): Atom<State>
|
||||
label = name
|
||||
return self
|
||||
end
|
||||
|
||||
function atom:unmount()
|
||||
table.clear(listeners)
|
||||
table.clear(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
|
||||
end
|
||||
|
||||
return atom
|
||||
53
src/derive.luau
Normal file
53
src/derive.luau
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
local atom = require(script.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
type ReadonlyAtom<T> = atom.ReadonlyAtom<T>
|
||||
|
||||
local reference = require(script.Parent.utils.reference)
|
||||
|
||||
type Derive =
|
||||
(<A, T>(Atom<A>, (A) -> T) -> ReadonlyAtom<T>)
|
||||
& (<A, B, T>(Atom<A>, Atom<B>, (A, B) -> T) -> ReadonlyAtom<T>)
|
||||
& (<A, B, C, T>(Atom<A>, Atom<B>, Atom<C>, (A, B, C) -> T) -> ReadonlyAtom<T>)
|
||||
& (<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 atoms: { Atom<unknown> } = { ... }
|
||||
local combiner: (...unknown) -> Result = table.remove(atoms) :: any
|
||||
local size = #atoms
|
||||
|
||||
local dependencies: { unknown } = {}
|
||||
|
||||
for index, atom in atoms do
|
||||
dependencies[index] = atom:get()
|
||||
end
|
||||
|
||||
local derivedAtom = atom(combiner(table.unpack(dependencies, 1, size))):readonly()
|
||||
local getDerivedAtom = reference.weak(derivedAtom)
|
||||
|
||||
for index, atom in atoms do
|
||||
local function listener(state: unknown)
|
||||
-- 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
|
||||
end
|
||||
|
||||
return derivedAtom
|
||||
end
|
||||
|
||||
return derive :: Derive
|
||||
89
src/index.d.ts
vendored
Normal file
89
src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
export = Charm;
|
||||
export as namespace Charm;
|
||||
|
||||
declare namespace Charm {
|
||||
interface InternalAtom<State> {
|
||||
/** @hidden */
|
||||
readonly listeners: Set<(value: State) => void>;
|
||||
/** @hidden */
|
||||
readonly debugTraceback: string;
|
||||
/** @hidden */
|
||||
readonly debugLabel: string;
|
||||
/** @hidden */
|
||||
setForced(state: State | ((current: State) => State)): void;
|
||||
}
|
||||
|
||||
interface BaseAtom<State> extends InternalAtom<State> {
|
||||
get(): State;
|
||||
get<Result>(selector: (state: State) => Result): Result;
|
||||
set(state: State | ((current: State) => State)): void;
|
||||
memo(equalityFn: (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> {
|
||||
(): State;
|
||||
(state: State | ((previous: State) => State)): void;
|
||||
}
|
||||
|
||||
interface ReadonlyAtom<State> extends BaseAtom<State> {
|
||||
(): State;
|
||||
set: never;
|
||||
}
|
||||
|
||||
type AtomStates<T extends Atom<any>[]> = {
|
||||
[Key in keyof T]: T[Key] extends Atom<infer State> ? State : never;
|
||||
};
|
||||
|
||||
function atom<State>(state: State): Atom<State>;
|
||||
|
||||
function isAtom(value: unknown): value is Atom<any>;
|
||||
|
||||
function derive<Atoms extends Atom<any>[], Result>(
|
||||
...args: [...atoms: Atoms, combiner: (...atoms: AtomStates<Atoms>) => Result]
|
||||
): ReadonlyAtom<Result>;
|
||||
|
||||
function subscribe<State>(atom: Atom<State>, callback: (state: State) => void): () => void;
|
||||
|
||||
function observe<K, V>(
|
||||
atom: Atom<Map<K, V> | ReadonlyMap<K, V>>,
|
||||
factory: (value: V, key: K) => (() => void) | void,
|
||||
): () => void;
|
||||
|
||||
function observe(atom: Atom<any>, factory: (value: unknown, key: unknown) => (() => void) | void): () => void;
|
||||
|
||||
function splitAtom<Item, Key, Id = Key>(
|
||||
listAtom: Atom<ReadonlyMap<Key, Item>> | Atom<Map<Key, Item>>,
|
||||
keyExtractor?: (item: Item) => Id,
|
||||
): Atom<ReadonlyMap<Id, Atom<Item>>>;
|
||||
|
||||
function splitAtom<Item>(listAtom: Atom<readonly Item[]>): Atom<readonly Atom<Item>[]>;
|
||||
|
||||
function splitAtom<Item, Id = number>(
|
||||
listAtom: Atom<readonly Item[]>,
|
||||
keyExtractor?: (item: Item) => Id,
|
||||
): Atom<ReadonlyMap<Id, Atom<Item>>>;
|
||||
|
||||
function splitAtom<Item, Key extends string | number | symbol, Id = Key>(
|
||||
listAtom: Atom<{ readonly [K in Key]?: Item }>,
|
||||
keyExtractor?: (item: Item) => Id,
|
||||
): Atom<ReadonlyMap<Id, Atom<Item>>>;
|
||||
|
||||
function useAtomState<State>(atom: Atom<State>): State;
|
||||
|
||||
function useAtomState<State, Result>(atom: Atom<State>, selector: (state: State) => Result): Result;
|
||||
|
||||
function useSetAtom<State>(atom: Atom<State>): (state: State | ((previous: State) => State)) => void;
|
||||
|
||||
function useAtom<State>(
|
||||
atom: Atom<State>,
|
||||
): LuaTuple<[state: State, setState: (state: State | ((previous: State) => State)) => void]>;
|
||||
|
||||
function useAtom<State, Result>(
|
||||
atom: Atom<State>,
|
||||
selector: (state: State) => Result,
|
||||
): LuaTuple<[state: Result, setState: (state: Result | ((previous: Result) => Result)) => void]>;
|
||||
}
|
||||
26
src/init.luau
Normal file
26
src/init.luau
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
_G[script.modules.Promise] = _G[script]
|
||||
|
||||
local atom = require(script.atom)
|
||||
local derive = require(script.derive)
|
||||
local isAtom = require(script.isAtom)
|
||||
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)
|
||||
|
||||
export type Atom<State> = atom.Atom<State>
|
||||
export type ReadonlyAtom<State> = atom.ReadonlyAtom<State>
|
||||
|
||||
return {
|
||||
atom = atom,
|
||||
derive = derive,
|
||||
isAtom = isAtom,
|
||||
observe = observe,
|
||||
split = split,
|
||||
subscribe = subscribe,
|
||||
useAtom = useAtom,
|
||||
useAtomState = useAtomState,
|
||||
useSetAtom = useSetAtom,
|
||||
}
|
||||
5
src/isAtom.luau
Normal file
5
src/isAtom.luau
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
local function isAtom(value: any)
|
||||
return type(value) == "table" and getmetatable(value) and getmetatable(value).__type == "Atom" or false
|
||||
end
|
||||
|
||||
return isAtom
|
||||
9
src/modules/Promise.luau
Normal file
9
src/modules/Promise.luau
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
local Package = script.Parent.Parent
|
||||
|
||||
if _G[script] then
|
||||
return _G[script].Promise
|
||||
end
|
||||
|
||||
assert(Package.Parent:FindFirstChild("Promise"), `Could not find Promise in {Package.Parent:GetFullName()}`)
|
||||
|
||||
return require(Package.Parent.Promise)
|
||||
8
src/modules/React.luau
Normal file
8
src/modules/React.luau
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
local Package = script.Parent.Parent
|
||||
local Packages = if Package.Parent:FindFirstChild("React")
|
||||
then Package.Parent
|
||||
else Package.Parent:FindFirstChild("ReactLua")
|
||||
|
||||
type React = typeof(require(Package.Parent.React))
|
||||
|
||||
return (if Packages then require(Packages.React) else nil) :: React
|
||||
8
src/modules/ReactRoblox.luau
Normal file
8
src/modules/ReactRoblox.luau
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
local Package = script.Parent.Parent
|
||||
local Packages = if Package.Parent:FindFirstChild("ReactRoblox")
|
||||
then Package.Parent
|
||||
else Package.Parent:FindFirstChild("ReactLua")
|
||||
|
||||
type ReactRoblox = typeof(require(Package.Parent.ReactRoblox))
|
||||
|
||||
return (if Packages then require(Packages.ReactRoblox) else nil) :: ReactRoblox
|
||||
38
src/observe.luau
Normal file
38
src/observe.luau
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
local atom = require(script.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
|
||||
local subscribe = require(script.Parent.subscribe)
|
||||
|
||||
local function observe<Item, Key>(atom: Atom<{ [Key]: Item }>, factory: (value: Item, key: Key) -> () -> ()): (() -> (), () -> ())
|
||||
local connections: { [Key]: () -> () } = {}
|
||||
|
||||
local function update(state: { [Key]: Item })
|
||||
for key, disconnect in connections do
|
||||
if state[key] == nil then
|
||||
connections[key] = nil
|
||||
disconnect()
|
||||
end
|
||||
end
|
||||
|
||||
for key, value in state do
|
||||
if not connections[key] then
|
||||
connections[key] = factory(value, key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local unsubscribe, flush = subscribe(atom, update)
|
||||
|
||||
local function cleanup()
|
||||
unsubscribe()
|
||||
for _, disconnect in connections do
|
||||
disconnect()
|
||||
end
|
||||
end
|
||||
|
||||
update(atom:get())
|
||||
|
||||
return cleanup, flush
|
||||
end
|
||||
|
||||
return observe
|
||||
19
src/react/useAtom.luau
Normal file
19
src/react/useAtom.luau
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
|
||||
local useAtomState = require(script.Parent.useAtomState)
|
||||
local useSetAtom = require(script.Parent.useSetAtom)
|
||||
|
||||
type UseAtom =
|
||||
(<State>(atom: Atom<State>) -> (State, useSetAtom.SetState<State>))
|
||||
& (<State, Result>(
|
||||
atom: Atom<State>,
|
||||
selector: ((State) -> Result)?
|
||||
) -> (State | Result, useSetAtom.SetState<State | Result>))
|
||||
& (<State, Result>(atom: Atom<State>, selector: (State) -> Result) -> (Result, useSetAtom.SetState<State>))
|
||||
|
||||
local function useAtom<State>(atom: Atom<State>, selector: ((State) -> State)?): (State, useSetAtom.SetState<State>)
|
||||
return useAtomState(atom, selector), useSetAtom(atom)
|
||||
end
|
||||
|
||||
return useAtom :: UseAtom
|
||||
35
src/react/useAtomState.luau
Normal file
35
src/react/useAtomState.luau
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
local React = require(script.Parent.Parent.modules.React)
|
||||
|
||||
local atom = require(script.Parent.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
|
||||
type UseAtomState =
|
||||
(<State>(atom: Atom<State>) -> State)
|
||||
& (<State, Result>(atom: Atom<State>, ((State) -> Result)?) -> State | Result)
|
||||
& <State, Result>(atom: Atom<State>, selector: (State) -> Result) -> Result
|
||||
|
||||
local function useAtomState<State>(atom: Atom<State>, selector: ((State) -> State)?): State
|
||||
local state, setState = React.useState(function()
|
||||
return atom:get(selector :: (State) -> State)
|
||||
end)
|
||||
|
||||
local latestSelector = React.useRef(selector)
|
||||
latestSelector.current = selector
|
||||
|
||||
React.useEffect(function()
|
||||
local function listener(state: State)
|
||||
local selector = latestSelector.current
|
||||
setState(if selector then selector(state) else state)
|
||||
end
|
||||
|
||||
atom.listeners[listener] = true
|
||||
|
||||
return function()
|
||||
atom.listeners[listener] = nil
|
||||
end
|
||||
end, { atom })
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
return useAtomState :: UseAtomState
|
||||
14
src/react/useSetAtom.luau
Normal file
14
src/react/useSetAtom.luau
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
local React = require(script.Parent.Parent.modules.React)
|
||||
|
||||
local atom = require(script.Parent.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
|
||||
export type SetState<T> = (T | ((T) -> T)) -> ()
|
||||
|
||||
local function useSetAtom<State>(atom: Atom<State>): SetState<State>
|
||||
return React.useCallback(function(value)
|
||||
atom:set(value)
|
||||
end, { atom })
|
||||
end
|
||||
|
||||
return useSetAtom
|
||||
75
src/split.luau
Normal file
75
src/split.luau
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
local atom = require(script.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
type ReadonlyAtom<T> = atom.ReadonlyAtom<T>
|
||||
|
||||
local reference = require(script.Parent.utils.reference)
|
||||
|
||||
type Split =
|
||||
(<Item, Key>(atom: Atom<{ [Key]: Item }>) -> ReadonlyAtom<{ [Key]: Atom<Item> }>)
|
||||
& (<Item, Key>(atom: Atom<{ [any]: Item }>, keyExtractor: (Item) -> Key) -> ReadonlyAtom<{ [Key]: Atom<Item> }>)
|
||||
|
||||
local function split<Item, Key>(
|
||||
itemsAtom: Atom<{ [Key]: Item }>,
|
||||
keyExtractor: ((Item) -> Key)?
|
||||
): ReadonlyAtom<{ [Key]: Atom<Item> }>
|
||||
local splitAtom: ReadonlyAtom<{ [Key]: Atom<Item> }> = atom({}):readonly()
|
||||
local getSplitAtom = reference.weak(splitAtom)
|
||||
|
||||
local previousAtoms: { [Key]: Atom<Item> } = {}
|
||||
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
|
||||
|
||||
if itemsByKeys[id] == nil then
|
||||
atoms[id]:unmount()
|
||||
atoms[id] = nil
|
||||
end
|
||||
end
|
||||
|
||||
previousAtoms, previousItems = atoms, items
|
||||
splitAtom:setForced(atoms)
|
||||
end
|
||||
|
||||
itemsAtom.listeners[listener] = true
|
||||
listener(itemsAtom:get())
|
||||
|
||||
return splitAtom
|
||||
end
|
||||
|
||||
return (split :: any) :: Split
|
||||
46
src/subscribe.luau
Normal file
46
src/subscribe.luau
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
local atom = require(script.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
|
||||
local reference = require(script.Parent.utils.reference)
|
||||
|
||||
local function subscribe<State>(atom: Atom<State>, callback: (state: State, lastState: State) -> ()): (() -> (), () -> ())
|
||||
local pending: thread? = nil
|
||||
local state = atom:get()
|
||||
|
||||
local function emit()
|
||||
local lastState = state
|
||||
state = atom:get()
|
||||
callback(state, lastState)
|
||||
end
|
||||
|
||||
local function update()
|
||||
pending = pending or task.defer(function()
|
||||
pending = nil
|
||||
emit()
|
||||
end)
|
||||
end
|
||||
|
||||
local function flush()
|
||||
if pending then
|
||||
pending = task.cancel(pending)
|
||||
emit()
|
||||
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
|
||||
|
||||
return subscribe
|
||||
31
src/utils/reference.luau
Normal file
31
src/utils/reference.luau
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
local refs = {}
|
||||
|
||||
--- Prevents the garbage collector from collecting a value
|
||||
local function hold(value: unknown)
|
||||
refs[value] = true
|
||||
end
|
||||
|
||||
--- Allows the garbage collector to collect a value
|
||||
local function release(value: unknown)
|
||||
refs[value] = nil
|
||||
end
|
||||
|
||||
--- Clears all held references
|
||||
local function clear()
|
||||
table.clear(refs)
|
||||
end
|
||||
|
||||
--- Returns a function that retrieves the value through a weak reference
|
||||
local function weak<T>(value: T): () -> T?
|
||||
local set = setmetatable({ value }, { __mode = "v" })
|
||||
return function()
|
||||
return set[1]
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
hold = hold,
|
||||
release = release,
|
||||
clear = clear,
|
||||
weak = weak,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue