mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
refactor!: design overhaul (#1)
* refactor!: use atom functions * docs: reformat api reference * docs: showcase atom functions * docs: remove unused import * refactor: finalize updates * chore: misc edits
This commit is contained in:
parent
4d4979daa9
commit
33ecae2f50
39 changed files with 577 additions and 729 deletions
|
|
@ -1,4 +1,5 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local store = require(script.Parent.Parent.store)
|
||||
|
||||
return function()
|
||||
it("updates state", function()
|
||||
|
|
@ -14,10 +15,10 @@ return function()
|
|||
it("calls listeners", function()
|
||||
local source = atom(1)
|
||||
local value
|
||||
local function listener(state)
|
||||
value = state
|
||||
local function listener()
|
||||
value = source()
|
||||
end
|
||||
source.listeners[listener] = true
|
||||
store.listeners[source][listener] = true
|
||||
source(2)
|
||||
expect(value).to.equal(2)
|
||||
end)
|
||||
|
|
@ -31,9 +32,9 @@ return function()
|
|||
local function listener()
|
||||
inner += 1
|
||||
end
|
||||
source.listeners[listener] = true
|
||||
store.listeners[source][listener] = true
|
||||
end
|
||||
source.listeners[listener] = true
|
||||
store.listeners[source][listener] = true
|
||||
source(2)
|
||||
expect(outer).to.equal(1)
|
||||
expect(inner).to.equal(0)
|
||||
|
|
@ -42,44 +43,17 @@ return function()
|
|||
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)
|
||||
it("takes a custom comparator", function()
|
||||
local max = atom(1, {
|
||||
equals = function(prev, next)
|
||||
return next < prev
|
||||
end,
|
||||
})
|
||||
max(2)
|
||||
expect(max()).to.equal(2)
|
||||
max(1)
|
||||
expect(max()).to.equal(2)
|
||||
max(3)
|
||||
expect(max()).to.equal(3)
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,48 +6,41 @@ local Package = game:GetService("ReplicatedStorage").Packages.charm
|
|||
local atom = require(Package.atom)
|
||||
local derive = require(Package.derive)
|
||||
|
||||
return {
|
||||
ParameterGenerator = function()
|
||||
return atom({ count = 0 })
|
||||
local function parameterGenerator()
|
||||
return atom({ count = 0 })
|
||||
end
|
||||
|
||||
local benchmarks = {
|
||||
atom = function(_, source: any)
|
||||
for _ = 1, 500 do
|
||||
source({ count = math.random(1, 2) })
|
||||
end
|
||||
end,
|
||||
|
||||
Functions = {
|
||||
atom = function(_, source: any)
|
||||
for _ = 1, 500 do
|
||||
source({ count = math.random(1, 2) })
|
||||
end
|
||||
end,
|
||||
derived = function(_, source: any)
|
||||
derive(function()
|
||||
return { count = 2 * source().count }
|
||||
end)
|
||||
for _ = 1, 500 do
|
||||
source({ count = math.random(1, 2) })
|
||||
end
|
||||
end,
|
||||
|
||||
derived = function(_, source: any)
|
||||
derive(source, function(state)
|
||||
return { count = 2 * state.count }
|
||||
end)
|
||||
for _ = 1, 500 do
|
||||
source({ count = math.random(1, 2) })
|
||||
end
|
||||
end,
|
||||
|
||||
derived_memo = function(_, source: any)
|
||||
source:memo(function(previous, current)
|
||||
derived_memo = function(_, source: any)
|
||||
derive(function()
|
||||
return { count = 2 * source().count }
|
||||
end, {
|
||||
equals = function(previous, current)
|
||||
return previous.count == current.count
|
||||
end)
|
||||
derive(source, function(state)
|
||||
return { count = 2 * state.count }
|
||||
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,
|
||||
},
|
||||
end,
|
||||
})
|
||||
for _ = 1, 500 do
|
||||
source({ count = math.random(1, 2) })
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
return {
|
||||
ParameterGenerator = parameterGenerator,
|
||||
Functions = benchmarks,
|
||||
}
|
||||
|
|
|
|||
22
src/__tests__/capture.spec.luau
Normal file
22
src/__tests__/capture.spec.luau
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local capture = require(script.Parent.Parent.capture)
|
||||
|
||||
return function()
|
||||
it("captures atoms from a function", function()
|
||||
local a, b, c = atom(1), atom(1), atom(2)
|
||||
local captured, result = capture(function()
|
||||
return a() + b()
|
||||
end)
|
||||
expect(captured[a]).to.be.ok()
|
||||
expect(captured[b]).to.be.ok()
|
||||
expect(captured[c]).to.never.be.ok()
|
||||
expect(result).to.equal(2)
|
||||
end)
|
||||
|
||||
it("captures an atom by itself", function()
|
||||
local a = atom(1)
|
||||
local captured, result = capture(a)
|
||||
expect(captured[a]).to.be.ok()
|
||||
expect(result).to.equal(1)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,64 +1,40 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local derive = require(script.Parent.Parent.derive)
|
||||
local store = require(script.Parent.Parent.store)
|
||||
|
||||
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)
|
||||
local a = atom(1)
|
||||
local b = atom(2)
|
||||
local double = derive(function()
|
||||
calls += 1
|
||||
return value * 2
|
||||
return a() * b()
|
||||
end)
|
||||
expect(calls).to.equal(1)
|
||||
source(1)
|
||||
a(1)
|
||||
expect(calls).to.equal(1)
|
||||
source(2)
|
||||
a(2)
|
||||
expect(calls).to.equal(2)
|
||||
b(3)
|
||||
expect(calls).to.equal(3)
|
||||
double()
|
||||
expect(calls).to.equal(2)
|
||||
expect(calls).to.equal(3)
|
||||
end)
|
||||
|
||||
it("can be a dependency", function()
|
||||
it("can be nested", function()
|
||||
local source = atom(1)
|
||||
local double = derive(source, function(value: number)
|
||||
return value * 2
|
||||
local double = derive(function()
|
||||
return source() * 2
|
||||
end)
|
||||
local quadruple = derive(double, function(value: number)
|
||||
return value * 2
|
||||
local quadruple = derive(function()
|
||||
return double() * 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))
|
||||
|
|
@ -66,7 +42,7 @@ return function()
|
|||
|
||||
local function listenersDecreased()
|
||||
local count = 0
|
||||
for _ in source.listeners do
|
||||
for _ in pairs(store.listeners[source]) do
|
||||
count += 1
|
||||
end
|
||||
max = math.max(max, count)
|
||||
|
|
@ -74,11 +50,11 @@ return function()
|
|||
end
|
||||
|
||||
for index = 1, 500 do
|
||||
derive(source, function(value)
|
||||
return string.upper(value)
|
||||
derive(function()
|
||||
return string.upper(source())
|
||||
end)
|
||||
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")
|
||||
task.wait()
|
||||
end
|
||||
|
|
@ -89,14 +65,4 @@ return function()
|
|||
|
||||
error("None of the derived atoms were collected")
|
||||
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
|
||||
|
|
|
|||
30
src/__tests__/effect.spec.luau
Normal file
30
src/__tests__/effect.spec.luau
Normal 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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
local reference = require(script.Parent.Parent.utils.reference)
|
||||
local store = require(script.Parent.Parent.store)
|
||||
|
||||
return function()
|
||||
local previous = _G.__DEV__
|
||||
|
|
@ -12,6 +12,6 @@ return function()
|
|||
end)
|
||||
|
||||
afterEach(function()
|
||||
reference.clear()
|
||||
table.clear(store.listeners :: any)
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local mapAtom = require(script.Parent.Parent.mapAtom)
|
||||
local map = require(script.Parent.Parent.map)
|
||||
local observe = require(script.Parent.Parent.observe)
|
||||
local store = require(script.Parent.Parent.store)
|
||||
|
||||
return function()
|
||||
it("returns mapped atoms", function()
|
||||
local itemsAtom = atom({ "a", "b", "c" })
|
||||
local mappedAtom = mapAtom(itemsAtom, function(value, key)
|
||||
local mappedAtom = map(itemsAtom, function(value, key)
|
||||
return string.upper(value), key
|
||||
end)
|
||||
local updates = 0
|
||||
|
||||
mappedAtom.listeners[function()
|
||||
store.listeners[mappedAtom][function()
|
||||
updates += 1
|
||||
end] = true
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ return function()
|
|||
|
||||
it("uses key extractor", function()
|
||||
local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } })
|
||||
local mappedAtom = mapAtom(itemsAtom, function(item)
|
||||
local mappedAtom = map(itemsAtom, function(item)
|
||||
return item, item.key
|
||||
end)
|
||||
|
||||
|
|
@ -60,14 +61,14 @@ return function()
|
|||
{ key = 3, value = "c" },
|
||||
})
|
||||
|
||||
local mappedAtom = mapAtom(itemsAtom, function(item)
|
||||
local mappedAtom = map(itemsAtom, function(item)
|
||||
return item, item.key
|
||||
end)
|
||||
|
||||
local added = 0
|
||||
local deleted = 0
|
||||
|
||||
local _, flush = observe(mappedAtom, function(item, key)
|
||||
observe(mappedAtom, function(item, key)
|
||||
expect(item).to.equal(itemsAtom()[key])
|
||||
added += 1
|
||||
|
||||
|
|
@ -81,25 +82,13 @@ return function()
|
|||
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
|
||||
|
|
@ -6,7 +6,7 @@ return function()
|
|||
local itemsAtom = atom({ "a", "b", "c" })
|
||||
local state = {}
|
||||
|
||||
local cleanup, flush = observe(itemsAtom, function(item, key)
|
||||
local cleanup = observe(itemsAtom, function(item, key)
|
||||
state[key] = item
|
||||
return function()
|
||||
state[key] = nil
|
||||
|
|
@ -14,14 +14,13 @@ return function()
|
|||
end)
|
||||
|
||||
for key = 1, 3 do
|
||||
expect(state[key]).to.equal(itemsAtom:get()[key])
|
||||
expect(state[key]).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
itemsAtom:set({ [4] = "d", [5] = "e", [6] = "f" })
|
||||
flush()
|
||||
itemsAtom({ [4] = "d", [5] = "e", [6] = "f" })
|
||||
|
||||
for key = 1, 6 do
|
||||
expect(state[key]).to.equal(itemsAtom:get()[key])
|
||||
expect(state[key]).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
cleanup()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ _G.__ROACT_17_MOCK_SCHEDULER__ = true
|
|||
|
||||
local Package = script:FindFirstAncestor("__tests__").Parent
|
||||
|
||||
local React = require(Package.modules.React)
|
||||
local React = require(Package.modules.React)()
|
||||
local ReactRoblox = require(Package.modules.ReactRoblox)
|
||||
|
||||
type RenderedHook<Props, Result> = {
|
||||
|
|
|
|||
|
|
@ -24,11 +24,11 @@ return function()
|
|||
end)
|
||||
|
||||
it("memoizes the function", function()
|
||||
local hook = renderHook(function(source)
|
||||
return useSetAtom(source)
|
||||
end, atom(0))
|
||||
local hook = renderHook(function(props)
|
||||
return useSetAtom(props.source)
|
||||
end, { source = atom(0) })
|
||||
local fn = hook.result
|
||||
hook.rerender(atom(1))
|
||||
hook.rerender({ source = atom(1) })
|
||||
expect(hook.result).to.never.equal(fn)
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,62 +6,23 @@ return function()
|
|||
it("runs on state change", function()
|
||||
local current, previous
|
||||
local source = atom(0)
|
||||
local unsubscribe, flush = subscribe(source, function(...)
|
||||
local unsubscribe = 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
|
||||
|
|
@ -70,15 +31,13 @@ return function()
|
|||
-- 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.
|
||||
local derived = derive(function()
|
||||
return source()
|
||||
end)
|
||||
subscribe(
|
||||
derive(
|
||||
derive(source, function(x)
|
||||
return x
|
||||
end),
|
||||
function(x)
|
||||
return 2 * x
|
||||
end
|
||||
),
|
||||
derive(function()
|
||||
return derived() * 2
|
||||
end),
|
||||
function()
|
||||
updates += 1
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
local atom = require(script.Parent.Parent.Parent.atom)
|
||||
local sync = require(script.Parent.Parent.Parent.sync)
|
||||
local types = require(script.Parent.Parent.Parent.types)
|
||||
|
||||
return function()
|
||||
local target: Player = {} :: any
|
||||
|
|
@ -7,9 +8,9 @@ return function()
|
|||
it("sends state to new players", function()
|
||||
local a = atom(1)
|
||||
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
||||
local player: Player, payload: sync.SyncPayload
|
||||
local player: Player, payload: types.SyncPayload
|
||||
|
||||
server:onSync(function(...)
|
||||
local disconnect = server:connect(function(...)
|
||||
player, payload = ...
|
||||
end)
|
||||
|
||||
|
|
@ -18,14 +19,16 @@ return function()
|
|||
expect(player).to.equal(target)
|
||||
expect(payload.type).to.equal("set")
|
||||
expect(payload.data.a).to.equal(1)
|
||||
|
||||
disconnect()
|
||||
end)
|
||||
|
||||
it("sends state patches", function()
|
||||
local a = atom({ b = 1, c = 2 })
|
||||
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
||||
local player: Player, payload: sync.SyncPayload
|
||||
local player: Player, payload: types.SyncPayload
|
||||
|
||||
server:onSync(function(...)
|
||||
local disconnect = server:connect(function(...)
|
||||
player, payload = ...
|
||||
end)
|
||||
|
||||
|
|
@ -36,5 +39,7 @@ return function()
|
|||
expect(payload.type).to.equal("patch")
|
||||
expect(payload.data.a.b).to.never.be.ok()
|
||||
expect(payload.data.a.c).to.equal(5)
|
||||
|
||||
disconnect()
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
23
src/__tests__/unwrap.spec.luau
Normal file
23
src/__tests__/unwrap.spec.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local capture = require(script.Parent.Parent.capture)
|
||||
local unwrap = require(script.Parent.Parent.unwrap)
|
||||
|
||||
return function()
|
||||
it("calls an atom without capturing it", function()
|
||||
local a, b, c = atom(1), atom(1), atom(1)
|
||||
local captured, result = capture(function()
|
||||
return a() + unwrap(b) + c()
|
||||
end)
|
||||
expect(captured[a]).to.be.ok()
|
||||
expect(captured[b]).to.never.be.ok()
|
||||
expect(captured[c]).to.be.ok()
|
||||
expect(result).to.equal(3)
|
||||
end)
|
||||
|
||||
it("passes through arguments", function()
|
||||
expect(unwrap(1)).to.equal(1)
|
||||
expect(unwrap(function()
|
||||
return 1
|
||||
end)).to.equal(1)
|
||||
end)
|
||||
end
|
||||
132
src/atom.luau
132
src/atom.luau
|
|
@ -1,126 +1,34 @@
|
|||
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 AtomOptions<T> = types.AtomOptions<T>
|
||||
|
||||
type AtomInternal<T> = {
|
||||
setForced: (self: AtomInternal<T>, state: T | ((T) -> T)) -> (),
|
||||
listeners: { [(T) -> ()]: true },
|
||||
debugTraceback: string,
|
||||
debugLabel: string,
|
||||
owners: { [any]: true },
|
||||
}
|
||||
local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
|
||||
local equals = options and options.equals
|
||||
|
||||
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
|
||||
local function atom(...)
|
||||
if select("#", ...) == 0 then
|
||||
if store.shouldCapture() then
|
||||
store.captured[atom] = true
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
if not (if equals then equals(state, nextState) else state == nextState) then
|
||||
local nextState = if type(...) == "function" then (...)(state) else ...
|
||||
|
||||
if state ~= nextState and not (equals and equals(state, nextState)) then
|
||||
state = nextState
|
||||
|
||||
for listener in table.clone(self.listeners) do
|
||||
listener(state)
|
||||
for listener in pairs(table.clone(store.listeners[atom])) do
|
||||
listener()
|
||||
end
|
||||
end
|
||||
|
||||
return state
|
||||
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(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,
|
||||
})
|
||||
store.listeners[atom] = setmetatable({}, { __mode = "v" })
|
||||
|
||||
return atom
|
||||
end
|
||||
|
|
|
|||
25
src/capture.luau
Normal file
25
src/capture.luau
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
local store = require(script.Parent.store)
|
||||
local types = require(script.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type Source<T> = types.Source<T>
|
||||
|
||||
local function capture<T>(source: Source<T>): ({ [Atom<any>]: true }, T)
|
||||
if store.listeners[source] then
|
||||
return { [source] = true }, source()
|
||||
end
|
||||
|
||||
store.setCapturing(true)
|
||||
local success, result = pcall(source)
|
||||
store.setCapturing(false)
|
||||
|
||||
local dependencies = table.clone(store.captured)
|
||||
table.clear(store.captured)
|
||||
|
||||
if not success then
|
||||
warn("An error occurred while calling an atom:", result)
|
||||
end
|
||||
|
||||
return dependencies, result
|
||||
end
|
||||
|
||||
return capture
|
||||
|
|
@ -1,65 +1,27 @@
|
|||
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)
|
||||
local capture = require(script.Parent.capture)
|
||||
local store = require(script.Parent.store)
|
||||
local types = require(script.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type AtomOptions<T> = types.AtomOptions<T>
|
||||
type Source<T> = types.Source<T>
|
||||
|
||||
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<T>(source: Source<T>, options: AtomOptions<T>?): Source<T>
|
||||
local atoms, state = capture(source)
|
||||
local derived = atom(state, options)
|
||||
local derivedRef = setmetatable({ current = derived }, { __mode = "v" })
|
||||
|
||||
local function derive<Result>(...): ReadonlyAtom<Result>
|
||||
local atoms: { Atom<unknown> } = { ... }
|
||||
local combiner: (...unknown) -> Result = table.remove(atoms) :: any
|
||||
local size = 0
|
||||
local dependencies: { unknown } = {}
|
||||
local listeners: { [() -> ()]: boolean } = {}
|
||||
|
||||
for index, atom in atoms do
|
||||
dependencies[index] = atom:get()
|
||||
size += 1
|
||||
end
|
||||
|
||||
local derivedAtom = atom(combiner(table.unpack(dependencies, 1, size))):readonly()
|
||||
local derivedAtomUnmount = derivedAtom.unmount
|
||||
local getDerivedAtom = reference.weak(derivedAtom)
|
||||
|
||||
function derivedAtom:unmount()
|
||||
derivedAtomUnmount(self)
|
||||
|
||||
for listener in listeners do
|
||||
for _, atom in atoms do
|
||||
atom.listeners[listener] = nil
|
||||
end
|
||||
local function listener()
|
||||
if derivedRef.current then
|
||||
derivedRef.current(source())
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
listeners[listener] = true
|
||||
for atom in atoms do
|
||||
store.listeners[atom][listener] = derived
|
||||
end
|
||||
|
||||
return derivedAtom
|
||||
return derived
|
||||
end
|
||||
|
||||
return derive :: Derive
|
||||
return derive
|
||||
|
|
|
|||
22
src/effect.luau
Normal file
22
src/effect.luau
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
local capture = require(script.Parent.capture)
|
||||
local store = require(script.Parent.store)
|
||||
|
||||
local function effect(source: () -> nil): () -> ()
|
||||
local atoms = 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
|
||||
159
src/index.d.ts
vendored
159
src/index.d.ts
vendored
|
|
@ -1,134 +1,109 @@
|
|||
export = Charm;
|
||||
export as namespace Charm;
|
||||
|
||||
type Cleanup = () => void;
|
||||
|
||||
type AnyMap<K, V> =
|
||||
| Map<K, V>
|
||||
| ReadonlyMap<K, V>
|
||||
| (K extends string | number | symbol ? { readonly [Key in K]: V } : never);
|
||||
|
||||
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 Atom<T> extends Source<T> {
|
||||
readonly __nominal: unique symbol;
|
||||
(state: T | ((prev: T) => T)): void;
|
||||
}
|
||||
|
||||
interface BaseAtom<State> extends InternalAtom<State> {
|
||||
get(): State;
|
||||
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;
|
||||
type Source<T> = () => T;
|
||||
|
||||
interface AtomOptions<State> {
|
||||
equals?: (prev: State, next: State) => boolean;
|
||||
}
|
||||
|
||||
interface Atom<State> extends BaseAtom<State> {
|
||||
(): State;
|
||||
(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, options?: AtomOptions<State>): Atom<State>;
|
||||
|
||||
function isAtom(value: unknown): value is Atom<any>;
|
||||
|
||||
function derive<Atoms extends Atom<any>[], Result>(
|
||||
...args: [...atoms: Atoms, combiner: (...atoms: AtomArrayToStates<Atoms>) => Result]
|
||||
): ReadonlyAtom<Result>;
|
||||
function derive<State>(atom: Source<State>, options?: AtomOptions<State>): Source<State>;
|
||||
|
||||
function subscribe<State>(atom: Atom<State>, callback: (state: State) => void): () => void;
|
||||
function subscribe<State>(atom: Source<State>, callback: (state: State, prev: State) => void): Cleanup;
|
||||
|
||||
function observe<K, V>(
|
||||
atom: Atom<Map<K, V> | ReadonlyMap<K, V>>,
|
||||
factory: (value: V, key: K) => (() => void) | void,
|
||||
): () => void;
|
||||
function effect(callback: Source<void>): Cleanup;
|
||||
|
||||
function observe(atom: Atom<any>, factory: (value: unknown, key: unknown) => (() => void) | void): () => void;
|
||||
function unwrap<State>(atom: State | Source<State>): State;
|
||||
|
||||
function mapAtom<V0, K1, V1>(
|
||||
atom: Atom<readonly V0[]> | Atom<V0[]>,
|
||||
mapper: (value: V0, index: number) => LuaTuple<[V1 | undefined, K1]>,
|
||||
): ReadonlyAtom<ReadonlyMap<K1, V1>>;
|
||||
function capture<State>(atom: Source<State>): LuaTuple<[captured: Set<Atom<unknown>>, state: State]>;
|
||||
|
||||
function mapAtom<V0, V1>(
|
||||
atom: Atom<readonly V0[]> | Atom<V0[]>,
|
||||
mapper: (value: V0, index: number) => V1,
|
||||
): ReadonlyAtom<readonly V1[]>;
|
||||
function observe<Item>(
|
||||
atom: Source<readonly Item[]>,
|
||||
factory: (item: Item, index: number) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
function mapAtom<K0, V0, K1 = K0, V1 = V0>(
|
||||
atom: Atom<ReadonlyMap<K0, V0>> | Atom<Map<K0, V0>>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1,
|
||||
): ReadonlyAtom<ReadonlyMap<K1, V1>>;
|
||||
function observe<Key, Item>(
|
||||
atom: Source<AnyMap<Key, Item>>,
|
||||
factory: (item: Item, key: Key) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
function mapAtom<K0 extends string | number | symbol, V0, K1, V1>(
|
||||
atom: Atom<{ readonly [K in K0]: V0 }>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1,
|
||||
): ReadonlyAtom<ReadonlyMap<K1, V1>>;
|
||||
function map<V0, K1, V1>(
|
||||
atom: Source<readonly V0[]>,
|
||||
mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>,
|
||||
): Source<ReadonlyMap<K1, V1>>;
|
||||
|
||||
function useAtomState<State>(atom: Atom<State>): State;
|
||||
function map<V0, V1>(atom: Source<readonly V0[]>, mapper: (value: V0, index: number) => V1): Source<readonly V1[]>;
|
||||
|
||||
function useAtomState<State, Result>(atom: Atom<State>, selector: (state: State) => Result): Result;
|
||||
function map<K0, V0, K1 = K0, V1 = V0>(
|
||||
atom: Source<AnyMap<K0, V0>>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): Source<ReadonlyMap<K1, V1>>;
|
||||
|
||||
function useSetAtom<State>(atom: Atom<State>): (state: State | ((previous: State) => State)) => void;
|
||||
// React
|
||||
|
||||
function useAtomState<State>(atom: Source<State>): State;
|
||||
|
||||
function useAtomState<State, Result>(atom: Source<State>, selector: (state: State) => Result): Result;
|
||||
|
||||
function useSetAtom<State>(atom: Source<State>): (state: State | ((prev: State) => State)) => void;
|
||||
|
||||
function useAtom<State>(
|
||||
atom: Atom<State>,
|
||||
): LuaTuple<[state: State, setState: (state: State | ((previous: State) => State)) => void]>;
|
||||
atom: Source<State>,
|
||||
): LuaTuple<[state: State, setState: (state: State | ((prev: State) => State)) => void]>;
|
||||
|
||||
function useAtom<State, Result>(
|
||||
atom: Atom<State>,
|
||||
atom: Source<State>,
|
||||
selector: (state: State) => Result,
|
||||
): LuaTuple<[state: Result, setState: (state: Result | ((previous: Result) => Result)) => void]>;
|
||||
): LuaTuple<[state: Result, setState: (state: Result | ((prev: Result) => Result)) => void]>;
|
||||
|
||||
interface None {
|
||||
readonly __none: "__none";
|
||||
}
|
||||
// Sync
|
||||
|
||||
type StatePatch<States> = {
|
||||
readonly [K in keyof States]?: (States[K] extends object ? StatePatch<States[K]> : States[K]) | None;
|
||||
const sync: {
|
||||
client(options: ClientOptions): ClientSyncer;
|
||||
server(options: ServerOptions): ServerSyncer;
|
||||
};
|
||||
|
||||
type SyncPayload<States> = { type: "set"; data: States } | { type: "patch"; data: StatePatch<States> };
|
||||
|
||||
interface AtomMap {
|
||||
readonly [key: string]: Atom<any>;
|
||||
interface SyncPatch {
|
||||
[key: string | number | symbol]: SyncPatch | { __none: "__none" } | string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
type AtomMapToStates<Atoms extends AtomMap> = {
|
||||
readonly [Key in keyof Atoms]: Atoms[Key] extends Atom<infer State> ? State : never;
|
||||
};
|
||||
|
||||
interface ClientSyncOptions<Atoms extends AtomMap> {
|
||||
atoms: Atoms;
|
||||
interface SyncPayload {
|
||||
type: "set" | "patch";
|
||||
data: SyncPatch;
|
||||
}
|
||||
|
||||
interface ClientSyncer<States> {
|
||||
sync(payload: SyncPayload<States>): void;
|
||||
interface ClientOptions {
|
||||
atoms: Record<string, Atom<any>>;
|
||||
}
|
||||
|
||||
interface ServerSyncOptions<Atoms extends AtomMap> {
|
||||
atoms: Atoms;
|
||||
interface ServerOptions {
|
||||
atoms: Record<string, Atom<any>>;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
interface ServerSyncer<States> {
|
||||
onSync(callback: (player: Player, payload: SyncPayload<States>) => void): () => void;
|
||||
interface ClientSyncer {
|
||||
sync(payload: SyncPayload): void;
|
||||
}
|
||||
|
||||
interface ServerSyncer {
|
||||
connect(callback: (player: Player, payload: SyncPayload) => void): Cleanup;
|
||||
hydrate(player: Player): void;
|
||||
}
|
||||
|
||||
namespace sync {
|
||||
function client<Atoms extends AtomMap>(options: ClientSyncOptions<Atoms>): ClientSyncer<AtomMapToStates<Atoms>>;
|
||||
|
||||
function server<Atoms extends AtomMap>(options: ServerSyncOptions<Atoms>): ServerSyncer<AtomMapToStates<Atoms>>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,34 @@
|
|||
_G[script.modules.Promise] = _G[script]
|
||||
|
||||
local atom = require(script.atom)
|
||||
local capture = require(script.capture)
|
||||
local derive = require(script.derive)
|
||||
local effect = require(script.effect)
|
||||
local isAtom = require(script.isAtom)
|
||||
local mapAtom = require(script.mapAtom)
|
||||
local map = require(script.map)
|
||||
local observe = require(script.observe)
|
||||
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)
|
||||
local types = require(script.types)
|
||||
local unwrap = require(script.unwrap)
|
||||
|
||||
export type Atom<State> = atom.Atom<State>
|
||||
export type ReadonlyAtom<State> = atom.ReadonlyAtom<State>
|
||||
export type Atom<State> = types.Atom<State>
|
||||
export type Source<State> = types.Source<State>
|
||||
export type SyncPayload = types.SyncPayload
|
||||
|
||||
return {
|
||||
atom = atom,
|
||||
derive = derive,
|
||||
effect = effect,
|
||||
isAtom = isAtom,
|
||||
map = map,
|
||||
observe = observe,
|
||||
mapAtom = mapAtom,
|
||||
subscribe = subscribe,
|
||||
unwrap = unwrap,
|
||||
capture = capture,
|
||||
sync = sync,
|
||||
useAtom = useAtom,
|
||||
useAtomState = useAtomState,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
local store = require(script.Parent.store)
|
||||
|
||||
local function isAtom(value: any)
|
||||
return type(value) == "table" and getmetatable(value) and getmetatable(value).__type == "Atom" or false
|
||||
return value ~= nil and store.listeners[value] :: any ~= nil
|
||||
end
|
||||
|
||||
return isAtom
|
||||
|
|
|
|||
54
src/map.luau
Normal file
54
src/map.luau
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
local atom = require(script.Parent.atom)
|
||||
local subscribe = require(script.Parent.subscribe)
|
||||
local types = require(script.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type Source<T> = types.Source<T>
|
||||
|
||||
type Map =
|
||||
(<K0, V0, K1, V1>(atom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1)) -> Source<{ [K1]: V1 }>)
|
||||
& (<K0, V0, V1>(atom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Source<{ [K0]: V1 }>)
|
||||
& (<K0, V0, K1, V1>(atom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Source<{ [K1]: V1 }>)
|
||||
|
||||
local function map<K0, V0, K1, V1>(itemsAtom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Source<{ [K1]: V1 }>
|
||||
local mappedAtom = atom({})
|
||||
local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" })
|
||||
local prevMappedItems: { [K1]: V1 } = {}
|
||||
local unsubscribe
|
||||
|
||||
local function listener(items: { [K0]: V0 })
|
||||
if not mappedAtomRef.current then
|
||||
return unsubscribe()
|
||||
end
|
||||
|
||||
local mappedItems = table.clone(mappedAtomRef.current())
|
||||
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 prevMappedItems do
|
||||
if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then
|
||||
mappedItems[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
prevMappedItems = mappedItems
|
||||
mappedAtom(mappedItems)
|
||||
end
|
||||
|
||||
unsubscribe = subscribe(itemsAtom, listener)
|
||||
listener(itemsAtom())
|
||||
|
||||
return mappedAtom
|
||||
end
|
||||
|
||||
return map :: Map
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
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)
|
||||
|
||||
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 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
|
||||
|
|
@ -5,4 +5,11 @@ local Packages = if Package.Parent:FindFirstChild("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
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
local atom = require(script.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
local subscribe = require(script.Parent.subscribe)
|
||||
local effect = require(script.Parent.effect)
|
||||
local types = require(script.Parent.types)
|
||||
type Source<T> = types.Source<T>
|
||||
|
||||
local function observe<Item, Key>(atom: Atom<{ [Key]: Item }>, factory: (value: Item, key: Key) -> () -> ()): (() -> (), () -> ())
|
||||
local connections: { [Key]: () -> () } = {}
|
||||
local function observe<K, V>(source: Source<{ [K]: V }>, factory: (value: V, key: K) -> () -> ()): () -> ()
|
||||
local connections: { [K]: () -> () } = {}
|
||||
|
||||
local unsubscribe = effect(function()
|
||||
local state = source()
|
||||
|
||||
local function update(state: { [Key]: Item })
|
||||
for key, disconnect in connections do
|
||||
if state[key] == nil then
|
||||
connections[key] = nil
|
||||
|
|
@ -18,9 +20,7 @@ local function observe<Item, Key>(atom: Atom<{ [Key]: Item }>, factory: (value:
|
|||
connections[key] = factory(value, key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local unsubscribe, flush = subscribe(atom, update)
|
||||
end)
|
||||
|
||||
local function cleanup()
|
||||
unsubscribe()
|
||||
|
|
@ -29,9 +29,7 @@ local function observe<Item, Key>(atom: Atom<{ [Key]: Item }>, factory: (value:
|
|||
end
|
||||
end
|
||||
|
||||
update(atom:get())
|
||||
|
||||
return cleanup, flush
|
||||
return cleanup
|
||||
end
|
||||
|
||||
return observe
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
local types = require(script.Parent.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
|
||||
local useAtomState = require(script.Parent.useAtomState)
|
||||
local useSetAtom = require(script.Parent.useSetAtom)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
local types = require(script.Parent.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
|
||||
type UseAtomState =
|
||||
(<State>(atom: Atom<State>) -> State)
|
||||
|
|
@ -9,24 +10,20 @@ type UseAtomState =
|
|||
& (<State, Result>(atom: Atom<State>, selector: (State) -> Result) -> Result)
|
||||
|
||||
local function useAtomState<State>(atom: Atom<State>, selector: ((State) -> State)?): State
|
||||
local React = requireReact()
|
||||
|
||||
local state, setState = React.useState(function()
|
||||
return atom:get(selector :: (State) -> State)
|
||||
return if selector then atom(selector) else atom()
|
||||
end)
|
||||
|
||||
local latestSelector = React.useRef(selector)
|
||||
latestSelector.current = selector
|
||||
|
||||
React.useEffect(function()
|
||||
local function listener(state: State)
|
||||
return subscribe(atom, function(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)
|
||||
end, { atom })
|
||||
|
||||
return state
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
local types = require(script.Parent.Parent.types)
|
||||
type Atom<T> = types.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)
|
||||
return requireReact().useCallback(function(state: State | ((State) -> State)): ()
|
||||
atom(state)
|
||||
end, { atom })
|
||||
end
|
||||
|
||||
|
|
|
|||
23
src/store.luau
Normal file
23
src/store.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
local types = require(script.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type Source<T> = types.Source<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 setCapturing(value: boolean)
|
||||
capturing = value
|
||||
end
|
||||
|
||||
local function shouldCapture()
|
||||
return capturing
|
||||
end
|
||||
|
||||
return {
|
||||
listeners = listeners,
|
||||
captured = captured,
|
||||
setCapturing = setCapturing,
|
||||
shouldCapture = shouldCapture,
|
||||
}
|
||||
|
|
@ -1,45 +1,26 @@
|
|||
local atom = require(script.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
local reference = require(script.Parent.utils.reference)
|
||||
local capture = require(script.Parent.capture)
|
||||
local store = require(script.Parent.store)
|
||||
local types = require(script.Parent.types)
|
||||
type Source<T> = types.Source<T>
|
||||
|
||||
local function subscribe<State>(atom: Atom<State>, callback: (state: State, previous: State) -> ()): (() -> (), () -> ())
|
||||
local pending: thread? = nil
|
||||
local state = atom:get()
|
||||
local function subscribe<T>(source: Source<T>, callback: (state: T, prev: T) -> ()): () -> ()
|
||||
local atoms, state = capture(source)
|
||||
|
||||
local function emit()
|
||||
local lastState = state
|
||||
state = atom:get()
|
||||
callback(state, lastState)
|
||||
local function listener()
|
||||
local prevState = state
|
||||
state = source()
|
||||
callback(state, prevState)
|
||||
end
|
||||
|
||||
local function update()
|
||||
pending = pending or task.defer(function()
|
||||
pending = nil
|
||||
emit()
|
||||
end)
|
||||
for atom in atoms do
|
||||
store.listeners[atom][listener] = true
|
||||
end
|
||||
|
||||
local function flush()
|
||||
if pending then
|
||||
pending = task.cancel(pending)
|
||||
emit()
|
||||
return function()
|
||||
for atom in atoms do
|
||||
store.listeners[atom][listener] = nil
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
local types = require(script.Parent.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type SyncPayload = types.SyncPayload
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
|
||||
type ClientSyncOptions = {
|
||||
type ClientOptions = {
|
||||
atoms: { [string]: Atom<any> },
|
||||
}
|
||||
|
||||
type ClientSyncer = {
|
||||
sync: (self: ClientSyncer, payload: types.SyncPayload) -> (),
|
||||
sync: (self: ClientSyncer, payload: SyncPayload) -> (),
|
||||
}
|
||||
|
||||
local function client(options: ClientSyncOptions): ClientSyncer
|
||||
local function client(options: ClientOptions): ClientSyncer
|
||||
local atoms = options.atoms
|
||||
local self = {} :: ClientSyncer
|
||||
|
||||
function self:sync(payload)
|
||||
for key, state in payload.data do
|
||||
if payload.type == "patch" then
|
||||
atoms[key]:set(patch.apply(atoms[key]:get(), state))
|
||||
atoms[key](patch.apply(atoms[key](), state))
|
||||
else
|
||||
atoms[key]:set(state)
|
||||
atoms[key](state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
local client = require(script.client)
|
||||
local server = require(script.server)
|
||||
local types = require(script.types)
|
||||
|
||||
export type SyncPayload = types.SyncPayload
|
||||
|
||||
return {
|
||||
client = client,
|
||||
|
|
|
|||
|
|
@ -2,43 +2,48 @@ local validate = require(script.Parent.validate)
|
|||
|
||||
local NONE = { __none = "__none" }
|
||||
|
||||
local function diff(previousState: { [any]: any }, nextState: { [any]: any })
|
||||
local patch = table.clone(nextState)
|
||||
local warnings = {}
|
||||
|
||||
for key, previous in previousState do
|
||||
local function diff(prevState: { [any]: any }, nextState: { [any]: any })
|
||||
local patches = table.clone(nextState)
|
||||
|
||||
for key, previous in prevState do
|
||||
local next = nextState[key]
|
||||
|
||||
if previous == next then
|
||||
patch[key] = nil
|
||||
patches[key] = nil
|
||||
elseif next == nil then
|
||||
patch[key] = NONE
|
||||
patches[key] = NONE
|
||||
elseif type(previous) == "table" and type(next) == "table" then
|
||||
patch[key] = diff(previous, next)
|
||||
patches[key] = diff(previous, next)
|
||||
end
|
||||
end
|
||||
|
||||
if _G.__DEV__ then
|
||||
local success, result = pcall(function()
|
||||
for key, value in previousState do
|
||||
for key, value in prevState do
|
||||
validate(value, key)
|
||||
end
|
||||
for key, value in nextState do
|
||||
validate(value, key)
|
||||
if prevState[key] ~= value then
|
||||
validate(value, key)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
if not success then
|
||||
if not success and not warnings[result] then
|
||||
warnings[result] = true
|
||||
warn(result)
|
||||
end
|
||||
end
|
||||
|
||||
return patch
|
||||
return patches
|
||||
end
|
||||
|
||||
local function apply(state: { [any]: any }, patchState: { [any]: any })
|
||||
local function apply(state: { [any]: any }, patches: { [any]: any })
|
||||
local nextState = table.clone(state)
|
||||
|
||||
for key, patch in patchState do
|
||||
for key, patch in patches do
|
||||
local current = nextState[key]
|
||||
|
||||
if type(patch) == "table" and patch.__none == "__none" then
|
||||
|
|
|
|||
|
|
@ -1,69 +1,69 @@
|
|||
local Players = game:GetService("Players")
|
||||
|
||||
local atom = require(script.Parent.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
local types = require(script.Parent.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type SyncPayload = types.SyncPayload
|
||||
local setInterval = require(script.Parent.Parent.utils.setInterval)
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
|
||||
type ServerSyncOptions = {
|
||||
type ServerOptions = {
|
||||
atoms: { [string]: Atom<any> },
|
||||
interval: number?,
|
||||
}
|
||||
|
||||
type ServerSyncer = {
|
||||
onSync: (self: ServerSyncer, callback: (player: Player, payload: types.SyncPayload) -> ()) -> () -> (),
|
||||
flush: (self: ServerSyncer, player: Player?) -> (),
|
||||
connect: (self: ServerSyncer, callback: (player: Player, payload: SyncPayload) -> ()) -> () -> (),
|
||||
flush: (self: ServerSyncer, player: Player) -> (),
|
||||
hydrate: (self: ServerSyncer, player: Player) -> (),
|
||||
}
|
||||
|
||||
local function server(options: ServerSyncOptions): ServerSyncer
|
||||
local function server(options: ServerOptions): ServerSyncer
|
||||
local atoms = options.atoms
|
||||
local interval = options.interval or 0
|
||||
|
||||
local self = {} :: ServerSyncer
|
||||
local sync: (player: Player, payload: types.SyncPayload) -> ()
|
||||
local sync: (player: Player, payload: SyncPayload) -> ()
|
||||
local state: {}
|
||||
|
||||
local function getState()
|
||||
local state = {}
|
||||
for key, atom in atoms do
|
||||
state[key] = atom:get()
|
||||
state[key] = atom()
|
||||
end
|
||||
return state
|
||||
end
|
||||
|
||||
function self:onSync(callback)
|
||||
function self:connect(callback)
|
||||
sync = callback
|
||||
state = getState()
|
||||
|
||||
return setInterval(function()
|
||||
self:flush()
|
||||
local previous = state
|
||||
state = getState()
|
||||
|
||||
if state ~= previous then
|
||||
for _, player in Players:GetPlayers() do
|
||||
callback(player, {
|
||||
type = "patch",
|
||||
data = patch.diff(previous, state),
|
||||
})
|
||||
end
|
||||
end
|
||||
end, interval)
|
||||
end
|
||||
|
||||
function self:flush(player: Player?)
|
||||
local current = getState()
|
||||
local previous = state or current
|
||||
state = current
|
||||
function self:flush(player: Player)
|
||||
local previous = state
|
||||
state = getState()
|
||||
|
||||
if current ~= previous then
|
||||
local payload: types.SyncPayload = {
|
||||
type = "patch",
|
||||
data = patch.diff(previous, current),
|
||||
}
|
||||
|
||||
if player then
|
||||
return sync(player, payload)
|
||||
end
|
||||
|
||||
for _, player in Players:GetPlayers() do
|
||||
sync(player, payload)
|
||||
end
|
||||
end
|
||||
sync(player, {
|
||||
type = "patch",
|
||||
data = patch.diff(previous, state),
|
||||
})
|
||||
end
|
||||
|
||||
function self:hydrate(player)
|
||||
assert(sync, "onSync must be called before onJoin")
|
||||
assert(sync, "connect() must be called before hydrate()")
|
||||
|
||||
sync(player, {
|
||||
type = "set",
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
export type SyncPayload = {
|
||||
type: "patch" | "set",
|
||||
data: { [any]: any },
|
||||
}
|
||||
|
||||
return nil
|
||||
16
src/types.luau
Normal file
16
src/types.luau
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export type WeakMap<K, V> = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" }))
|
||||
|
||||
export type Atom<T> = (() -> T) & (state: T | (T) -> T) -> T
|
||||
|
||||
export type Source<T> = () -> T
|
||||
|
||||
export type AtomOptions<T> = {
|
||||
equals: (prev: T, next: T) -> boolean,
|
||||
}
|
||||
|
||||
export type SyncPayload = {
|
||||
type: "patch" | "set",
|
||||
data: { [any]: any },
|
||||
}
|
||||
|
||||
return nil
|
||||
23
src/unwrap.luau
Normal file
23
src/unwrap.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
local store = require(script.Parent.store)
|
||||
|
||||
local function unwrap<T>(atom: (() -> T) | T): T
|
||||
if type(atom) ~= "function" then
|
||||
return atom
|
||||
end
|
||||
|
||||
if not store.shouldCapture() then
|
||||
return atom()
|
||||
end
|
||||
|
||||
store.setCapturing(false)
|
||||
local success, result = pcall(atom)
|
||||
store.setCapturing(true)
|
||||
|
||||
if not success then
|
||||
error(`Failed to unwrap atom: ${result}`, 2)
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
return unwrap
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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