mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
refactor!: migrate to monorepo (#23)
* refactor!: create monorepo * chore: prepare release, set up workflows * refactor: rename `modules` to `src` * docs: add docs for all packages * refactor: rename root project file * chore: bump deps * build: rename `lint`, add `pack` script * revert: delete pack script This will not be useful for development.
This commit is contained in:
parent
b038bf1f70
commit
08c7e690d5
81 changed files with 917 additions and 570 deletions
|
|
@ -1,59 +0,0 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local store = require(script.Parent.Parent.store)
|
||||
|
||||
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()
|
||||
value = source()
|
||||
end
|
||||
store.listeners[source][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
|
||||
store.listeners[source][listener] = true
|
||||
end
|
||||
store.listeners[source][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("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
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local computed = require(script.Parent.Parent.computed)
|
||||
local effect = require(script.Parent.Parent.effect)
|
||||
local store = require(script.Parent.Parent.store)
|
||||
local subscribe = require(script.Parent.Parent.subscribe)
|
||||
|
||||
return function()
|
||||
it("batches updates", function()
|
||||
local source = atom(1)
|
||||
local value = 0
|
||||
subscribe(source, function()
|
||||
value += 1
|
||||
end)
|
||||
store.batch(function()
|
||||
source(2)
|
||||
source(3)
|
||||
source(4)
|
||||
end)
|
||||
expect(value).to.equal(1)
|
||||
end)
|
||||
|
||||
it("handles duplicate listeners", function()
|
||||
local a = atom(1)
|
||||
local b = atom(1)
|
||||
local value = 0
|
||||
effect(function()
|
||||
value += 1
|
||||
a()
|
||||
b()
|
||||
end)
|
||||
value = 0 -- effect() will run once initially
|
||||
store.batch(function()
|
||||
a(2)
|
||||
b(2)
|
||||
a(3)
|
||||
end)
|
||||
expect(value).to.equal(1)
|
||||
end)
|
||||
|
||||
it("batches computed atoms", function()
|
||||
local source = atom(1)
|
||||
local updates = 0
|
||||
local computations = 0
|
||||
local double = computed(function()
|
||||
computations += 1
|
||||
return source() * 2
|
||||
end)
|
||||
computations = 0 -- computed atoms will run once initially
|
||||
subscribe(double, function()
|
||||
updates += 1
|
||||
end)
|
||||
store.batch(function()
|
||||
source(2)
|
||||
source(3)
|
||||
source(4)
|
||||
end)
|
||||
expect(updates).to.equal(1)
|
||||
expect(computations).to.equal(1)
|
||||
expect(double()).to.equal(8)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
-- This file is for use by Benchmarker
|
||||
-- https://boatbomber.itch.io/benchmarker
|
||||
|
||||
local Package = game:GetService("ReplicatedStorage").DevPackages.Charm
|
||||
|
||||
local atom = require(Package.atom)
|
||||
local computed = require(Package.computed)
|
||||
|
||||
local function parameterGenerator()
|
||||
return atom(0)
|
||||
end
|
||||
|
||||
local benchmarks = {
|
||||
atom = function(_, state: any)
|
||||
for _ = 1, 500 do
|
||||
state(math.random(1, 2))
|
||||
end
|
||||
end,
|
||||
|
||||
computed = function(_, state: any)
|
||||
computed(function()
|
||||
return 2 * state()
|
||||
end)
|
||||
|
||||
for _ = 1, 500 do
|
||||
state(math.random(1, 2))
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
return {
|
||||
ParameterGenerator = parameterGenerator,
|
||||
Functions = benchmarks,
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local store = require(script.Parent.Parent.store)
|
||||
local subscribe = require(script.Parent.Parent.subscribe)
|
||||
|
||||
return function()
|
||||
it("gracefully handles yielding", function()
|
||||
expect(function()
|
||||
store.capture(function()
|
||||
task.wait(10)
|
||||
return 1
|
||||
end)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("captures atoms from a function", function()
|
||||
local a, b, c = atom(1), atom(1), atom(2)
|
||||
local deps, result = store.capture(function()
|
||||
return a() + b()
|
||||
end)
|
||||
expect(deps[a]).to.be.ok()
|
||||
expect(deps[b]).to.be.ok()
|
||||
expect(deps[c]).to.never.be.ok()
|
||||
expect(result).to.equal(2)
|
||||
end)
|
||||
|
||||
it("captures an atom by itself", function()
|
||||
local a = atom(1)
|
||||
local deps, result = store.capture(a)
|
||||
expect(deps[a]).to.be.ok()
|
||||
expect(result).to.equal(1)
|
||||
end)
|
||||
|
||||
it("skips atoms in inner capture calls", function()
|
||||
local a = atom(1)
|
||||
local b = atom(1)
|
||||
local inner
|
||||
local outer = store.capture(function()
|
||||
inner = store.capture(function()
|
||||
return b()
|
||||
end)
|
||||
return a()
|
||||
end)
|
||||
|
||||
assert(outer[a], "outer did not capture a")
|
||||
assert(outer[b] == nil, "outer captured b")
|
||||
assert(inner[a] == nil, "inner captured a")
|
||||
assert(inner[b], "inner did not capture b")
|
||||
end)
|
||||
|
||||
it("should not capture notified dependents", function()
|
||||
local state = atom(1)
|
||||
|
||||
subscribe(state, function() end)
|
||||
|
||||
local deps = store.capture(function()
|
||||
return state(2)
|
||||
end)
|
||||
|
||||
expect(deps[state]).to.never.be.ok()
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local computed = require(script.Parent.Parent.computed)
|
||||
local store = require(script.Parent.Parent.store)
|
||||
local collect = require(script.Parent.Parent.utils.collect)
|
||||
local count = require(script.Parent.Parent.utils.count)
|
||||
|
||||
return function()
|
||||
it("memoizes the result", function()
|
||||
local calls = 0
|
||||
local a = atom(1)
|
||||
local b = atom(2)
|
||||
local double = computed(function()
|
||||
calls += 1
|
||||
return a() * b()
|
||||
end)
|
||||
expect(calls).to.equal(1)
|
||||
a(1)
|
||||
expect(calls).to.equal(1)
|
||||
a(2)
|
||||
expect(calls).to.equal(2)
|
||||
b(3)
|
||||
expect(calls).to.equal(3)
|
||||
double()
|
||||
expect(calls).to.equal(3)
|
||||
end)
|
||||
|
||||
it("can be nested", function()
|
||||
local source = atom(1)
|
||||
local double = computed(function()
|
||||
return source() * 2
|
||||
end)
|
||||
local quadruple = computed(function()
|
||||
return double() * 2
|
||||
end)
|
||||
expect(quadruple()).to.equal(4)
|
||||
source(2)
|
||||
expect(quadruple()).to.equal(8)
|
||||
end)
|
||||
|
||||
it("collects computed atoms", function()
|
||||
local source = atom(0)
|
||||
|
||||
computed(function()
|
||||
return source()
|
||||
end)
|
||||
|
||||
collect()
|
||||
|
||||
assert(count(store.listeners[source]) == 0, "computed atom did not gc")
|
||||
end)
|
||||
|
||||
it("collects nested computed atoms", function()
|
||||
local source = atom(0)
|
||||
|
||||
do
|
||||
-- Declare this in another scope to ensure no references exist at the
|
||||
-- time of garbage collection.
|
||||
local double = computed(function()
|
||||
return source() * 2
|
||||
end)
|
||||
|
||||
computed(function()
|
||||
return double() * 2
|
||||
end)
|
||||
end
|
||||
|
||||
collect()
|
||||
|
||||
assert(count(store.listeners[source]) == 0, "nested computed atom did not gc")
|
||||
end)
|
||||
|
||||
it("tracks conditional dependencies", function()
|
||||
local condition = atom(false)
|
||||
local a = atom(0)
|
||||
local b = atom(1)
|
||||
local reruns = 0
|
||||
local current
|
||||
|
||||
-- assign variable to avoid gc
|
||||
local _ = computed(function()
|
||||
reruns += 1
|
||||
current = if condition() then a() else b()
|
||||
return current
|
||||
end)
|
||||
|
||||
expect(reruns).to.equal(1)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(true)
|
||||
expect(reruns).to.equal(2)
|
||||
expect(current).to.equal(0)
|
||||
|
||||
a(1)
|
||||
b(2)
|
||||
expect(reruns).to.equal(3)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(false)
|
||||
expect(reruns).to.equal(4)
|
||||
expect(current).to.equal(2)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
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)
|
||||
|
||||
it("accepts a cleanup function", function()
|
||||
local a = atom(0)
|
||||
local cleanups = 0
|
||||
local unsubscribe = effect(function()
|
||||
a()
|
||||
return function()
|
||||
cleanups += 1
|
||||
end
|
||||
end)
|
||||
|
||||
expect(cleanups).to.equal(0)
|
||||
a(1)
|
||||
expect(cleanups).to.equal(1)
|
||||
unsubscribe()
|
||||
expect(cleanups).to.equal(2)
|
||||
end)
|
||||
|
||||
it("tracks conditional dependencies", function()
|
||||
local condition = atom(false)
|
||||
local a = atom(0)
|
||||
local b = atom(1)
|
||||
local reruns = 0
|
||||
local current
|
||||
|
||||
effect(function()
|
||||
reruns += 1
|
||||
current = if condition() then a() else b()
|
||||
end)
|
||||
|
||||
expect(reruns).to.equal(1)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(true)
|
||||
expect(reruns).to.equal(2)
|
||||
expect(current).to.equal(0)
|
||||
|
||||
a(1)
|
||||
b(2)
|
||||
expect(reruns).to.equal(3)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(false)
|
||||
expect(reruns).to.equal(4)
|
||||
expect(current).to.equal(2)
|
||||
end)
|
||||
|
||||
it("allows self-cleanup", function()
|
||||
local a = atom(1)
|
||||
local cleanedUp = false
|
||||
local cleanup
|
||||
|
||||
cleanup = effect(function()
|
||||
if cleanup then
|
||||
assert(not cleanedUp, "cleanup ran twice")
|
||||
cleanup()
|
||||
cleanedUp = true
|
||||
end
|
||||
a()
|
||||
end)
|
||||
|
||||
a(2)
|
||||
a(3)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
local store = require(script.Parent.Parent.store)
|
||||
|
||||
return function()
|
||||
local previous = _G.__DEV__
|
||||
|
||||
beforeAll(function()
|
||||
_G.__DEV__ = true
|
||||
end)
|
||||
|
||||
afterAll(function()
|
||||
_G.__DEV__ = previous
|
||||
end)
|
||||
|
||||
afterEach(function()
|
||||
table.clear(store.listeners :: any)
|
||||
table.clear(store.capturing.stack)
|
||||
store.capturing.index = 0
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local mapped = require(script.Parent.Parent.mapped)
|
||||
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 = mapped(itemsAtom, function(value, key)
|
||||
return string.upper(value), key
|
||||
end)
|
||||
local updates = 0
|
||||
|
||||
store.listeners[mappedAtom][function()
|
||||
updates += 1
|
||||
end] = true
|
||||
|
||||
for key = 1, 3 do
|
||||
local item = mappedAtom()[key]
|
||||
expect(item).to.equal(string.upper(itemsAtom()[key]))
|
||||
end
|
||||
|
||||
itemsAtom({ "d", "e", "f" })
|
||||
expect(updates).to.equal(1)
|
||||
|
||||
for key = 1, 3 do
|
||||
local item = mappedAtom()[key]
|
||||
expect(item).to.equal(string.upper(itemsAtom()[key]))
|
||||
end
|
||||
|
||||
itemsAtom({ [1] = "d", [3] = "f" })
|
||||
expect(mappedAtom()[2]).to.never.be.ok()
|
||||
expect(updates).to.equal(2)
|
||||
end)
|
||||
|
||||
it("uses key extractor", function()
|
||||
local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } })
|
||||
local mappedAtom = mapped(itemsAtom, function(item)
|
||||
return item, item.key
|
||||
end)
|
||||
|
||||
for key = 1, 3 do
|
||||
local item = mappedAtom()[key]
|
||||
expect(item).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
local previous = mappedAtom()
|
||||
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
|
||||
|
||||
expect(mappedAtom()[1]).to.equal(previous[1])
|
||||
expect(mappedAtom()[1]).to.equal(itemsAtom()[1])
|
||||
expect(mappedAtom()[2]).to.never.be.ok()
|
||||
expect(mappedAtom()[3]).to.equal(previous[3])
|
||||
expect(mappedAtom()[3]).to.equal(itemsAtom()[2])
|
||||
end)
|
||||
|
||||
it("allows observe()", function()
|
||||
local itemsAtom = atom({
|
||||
{ key = 1, value = "a" },
|
||||
{ key = 2, value = "b" },
|
||||
{ key = 3, value = "c" },
|
||||
})
|
||||
|
||||
local mappedAtom = mapped(itemsAtom, function(item)
|
||||
return item, item.key
|
||||
end)
|
||||
|
||||
local added = 0
|
||||
local deleted = 0
|
||||
|
||||
observe(mappedAtom, function(item, key)
|
||||
expect(item).to.equal(itemsAtom()[key])
|
||||
added += 1
|
||||
|
||||
return function()
|
||||
expect(mappedAtom()[key]).to.never.be.ok()
|
||||
deleted += 1
|
||||
end
|
||||
end)
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(deleted).to.equal(0)
|
||||
|
||||
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(deleted).to.equal(1)
|
||||
|
||||
itemsAtom({})
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(deleted).to.equal(3)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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 = 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()[key])
|
||||
end
|
||||
|
||||
itemsAtom({ [4] = "d", [5] = "e", [6] = "f" })
|
||||
|
||||
for key = 1, 6 do
|
||||
expect(state[key]).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
cleanup()
|
||||
|
||||
for key = 1, 6 do
|
||||
expect(state[key]).to.equal(nil)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local store = require(script.Parent.Parent.store)
|
||||
|
||||
return function()
|
||||
it("calls an atom without capturing it", function()
|
||||
local a, b, c = atom(1), atom(1), atom(1)
|
||||
local deps, result = store.capture(function()
|
||||
return a() + store.peek(b) + c()
|
||||
end)
|
||||
expect(deps[a]).to.be.ok()
|
||||
expect(deps[b]).to.never.be.ok()
|
||||
expect(deps[c]).to.be.ok()
|
||||
expect(result).to.equal(3)
|
||||
end)
|
||||
|
||||
it("passes through arguments", function()
|
||||
expect(store.peek(1)).to.equal(1)
|
||||
expect(store.peek(function()
|
||||
return 1
|
||||
end)).to.equal(1)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
_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
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
return function()
|
||||
local previous = _G.__DEV__
|
||||
|
||||
beforeAll(function()
|
||||
_G.__DEV__ = false -- prevent React errors
|
||||
end)
|
||||
|
||||
afterAll(function()
|
||||
_G.__DEV__ = previous
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
local atom = require(script.Parent.Parent.Parent.atom)
|
||||
local useAtom = require(script.Parent.Parent.Parent.react.useAtom)
|
||||
local renderHook = require(script.Parent.helpers.renderHook)
|
||||
|
||||
return function()
|
||||
it("tracks atom state", function()
|
||||
local source = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useAtom(source)
|
||||
end)
|
||||
expect(hook.result).to.equal(0)
|
||||
source(1)
|
||||
expect(hook.result).to.equal(1)
|
||||
end)
|
||||
|
||||
it("accepts a molecule", function()
|
||||
local source = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useAtom(function()
|
||||
return 2 * source()
|
||||
end)
|
||||
end)
|
||||
expect(hook.result).to.equal(0)
|
||||
source(1)
|
||||
expect(hook.result).to.equal(2)
|
||||
end)
|
||||
|
||||
it("tracks dependencies", function()
|
||||
local source = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useAtom(source, { source })
|
||||
end)
|
||||
expect(hook.result).to.equal(0)
|
||||
source(1)
|
||||
expect(hook.result).to.equal(1)
|
||||
source = atom(0)
|
||||
hook.rerender()
|
||||
expect(hook.result).to.equal(0)
|
||||
source(1)
|
||||
expect(hook.result).to.equal(1)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local computed = require(script.Parent.Parent.computed)
|
||||
local store = require(script.Parent.Parent.store)
|
||||
local subscribe = require(script.Parent.Parent.subscribe)
|
||||
local collect = require(script.Parent.Parent.utils.collect)
|
||||
local count = require(script.Parent.Parent.utils.count)
|
||||
|
||||
return function()
|
||||
it("runs on state change", function()
|
||||
local current, previous
|
||||
local source = atom(0)
|
||||
local unsubscribe = subscribe(source, function(...)
|
||||
current, previous = ...
|
||||
end)
|
||||
|
||||
for index = 1, 3 do
|
||||
source(index)
|
||||
expect(current).to.equal(index)
|
||||
expect(previous).to.equal(index - 1)
|
||||
end
|
||||
|
||||
unsubscribe()
|
||||
source(4)
|
||||
|
||||
expect(current).to.equal(3)
|
||||
expect(previous).to.equal(2)
|
||||
end)
|
||||
|
||||
it("prevents garbage collect", function()
|
||||
local source = atom(1)
|
||||
local updates = 0
|
||||
|
||||
-- Create a new scope to encapsulate variables with a limited lifetime, like
|
||||
-- 'unsubscribe' and 'computed', to ensure they are garbage collected.
|
||||
--
|
||||
-- The idea here is to test whether subscribe() prevents every computed
|
||||
-- atom in a chain from being garbage collected. If any are GCed, then
|
||||
-- the subscription will stop updating and the test will fail.
|
||||
do
|
||||
local unsubscribe
|
||||
|
||||
do
|
||||
local double = computed(function()
|
||||
return source() * 2
|
||||
end)
|
||||
|
||||
unsubscribe = subscribe(
|
||||
computed(function()
|
||||
return double() * 2
|
||||
end),
|
||||
function()
|
||||
updates += 1
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
collect()
|
||||
source(source() + 1)
|
||||
|
||||
assert(updates == 1, "subscription stopped updating")
|
||||
assert(count(store.listeners[source]) == 1, "computed atom was garbage collected")
|
||||
|
||||
unsubscribe()
|
||||
end
|
||||
|
||||
collect()
|
||||
|
||||
assert(count(store.listeners[source]) == 0, "computed atom did not collect")
|
||||
end)
|
||||
|
||||
it("tracks conditional dependencies", function()
|
||||
local condition = atom(false)
|
||||
local a = atom(0)
|
||||
local b = atom(1)
|
||||
local reruns = 0
|
||||
local current
|
||||
|
||||
subscribe(function()
|
||||
return if condition() then a() else b()
|
||||
end, function(state)
|
||||
reruns += 1
|
||||
current = state
|
||||
end)
|
||||
|
||||
condition(true)
|
||||
expect(reruns).to.equal(1)
|
||||
expect(current).to.equal(0)
|
||||
|
||||
a(1)
|
||||
b(2)
|
||||
expect(reruns).to.equal(2)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(false)
|
||||
expect(reruns).to.equal(3)
|
||||
expect(current).to.equal(2)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
local atom = require(script.Parent.Parent.Parent.atom)
|
||||
local subscribe = require(script.Parent.Parent.Parent.subscribe)
|
||||
local sync = require(script.Parent.Parent.Parent.sync)
|
||||
|
||||
return function()
|
||||
it("syncs state payload", function()
|
||||
local a = atom(1)
|
||||
local client = sync.client({ atoms = { a = a } })
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = 2 },
|
||||
})
|
||||
|
||||
expect(a()).to.equal(2)
|
||||
end)
|
||||
|
||||
it("syncs state patch", function()
|
||||
local a = atom({ b = 1, c = 2 })
|
||||
local previous = a()
|
||||
local client = sync.client({ atoms = { a = a } })
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = a() },
|
||||
})
|
||||
|
||||
expect(a()).to.equal(previous)
|
||||
|
||||
client:sync({
|
||||
type = "patch",
|
||||
data = { a = { b = 3 } },
|
||||
})
|
||||
|
||||
expect(a()).to.never.equal(previous)
|
||||
expect(a().b).to.equal(3)
|
||||
expect(a().c).to.equal(2)
|
||||
end)
|
||||
|
||||
it("fixes sparse array patches", function()
|
||||
local a = atom({ 1 })
|
||||
local client = sync.client({ atoms = { a = a } })
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = a() },
|
||||
})
|
||||
|
||||
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
|
||||
-- was JSON-serialized and deserialized. The client should be able to catch
|
||||
-- this and convert the string key back to a number.
|
||||
client:sync({
|
||||
type = "patch",
|
||||
data = { a = { ["2"] = 2 } },
|
||||
})
|
||||
|
||||
expect(a()["2" :: any]).to.never.be.ok()
|
||||
expect(a()[2]).to.equal(2)
|
||||
end)
|
||||
|
||||
it("applies multiple patches", function()
|
||||
local a = atom(1)
|
||||
local client = sync.client({ atoms = { a = a } })
|
||||
local calls = {}
|
||||
|
||||
subscribe(a, function(value)
|
||||
table.insert(calls, value)
|
||||
end)
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = a() },
|
||||
})
|
||||
|
||||
client:sync({
|
||||
type = "patch",
|
||||
data = { a = 2 },
|
||||
}, {
|
||||
type = "patch",
|
||||
data = { a = 3 },
|
||||
})
|
||||
|
||||
expect(a()).to.equal(3)
|
||||
expect(calls[1]).to.equal(2)
|
||||
expect(calls[2]).to.equal(3)
|
||||
end)
|
||||
|
||||
it("batches patches", function()
|
||||
local a = atom(0)
|
||||
local b = atom(0)
|
||||
local client = sync.client({ atoms = { a = a, b = b } })
|
||||
local states = {}
|
||||
|
||||
subscribe(a, function()
|
||||
states.b = b()
|
||||
end)
|
||||
|
||||
subscribe(b, function()
|
||||
states.a = a()
|
||||
end)
|
||||
|
||||
client:sync({
|
||||
type = "init",
|
||||
data = { a = a(), b = b() },
|
||||
})
|
||||
|
||||
client:sync({
|
||||
type = "patch",
|
||||
data = { a = 1, b = 2 },
|
||||
})
|
||||
|
||||
expect(a()).to.equal(1)
|
||||
expect(b()).to.equal(2)
|
||||
expect(states.a).to.equal(1)
|
||||
expect(states.b).to.equal(2)
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
local HttpService = game:GetService("HttpService")
|
||||
local patch = require(script.Parent.Parent.Parent.sync.patch)
|
||||
|
||||
return function()
|
||||
local function serdes(value: { [any]: any })
|
||||
return HttpService:JSONDecode(HttpService:JSONEncode(value))
|
||||
end
|
||||
|
||||
it("returns a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local nextState = { foo = "bar", baz = "quux" }
|
||||
local patches = patch.diff(prevState, nextState)
|
||||
|
||||
expect(patches).to.be.ok()
|
||||
expect(patches.baz).to.equal("quux")
|
||||
expect(patches.foo).to.never.be.ok()
|
||||
end)
|
||||
|
||||
it("applies a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local patches = { baz = "quux", qaz = "qux" }
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
expect(applied).to.be.ok()
|
||||
expect(applied.baz).to.equal("quux")
|
||||
expect(applied.foo).to.equal("bar")
|
||||
expect(applied.qaz).to.equal("qux")
|
||||
end)
|
||||
|
||||
it("allows nested patches", function()
|
||||
local prevState = { foo = { bar = "baz" } }
|
||||
local nextState = { foo = { bar = "qux" } }
|
||||
local patches = patch.diff(prevState, nextState)
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
expect(applied.foo.bar).to.equal("qux")
|
||||
end)
|
||||
|
||||
it("preserves array density when applying", function()
|
||||
local prevState = { "a", "b", "c", "d" }
|
||||
local nextState = { ["2"] = "B", ["4"] = "D" }
|
||||
local applied = patch.apply(prevState, nextState)
|
||||
|
||||
expect(applied[1]).to.equal("a")
|
||||
expect(applied[2]).to.equal("B")
|
||||
expect(applied[3]).to.equal("c")
|
||||
expect(applied[4]).to.equal("D")
|
||||
|
||||
expect(applied["2"]).to.never.be.ok()
|
||||
expect(applied["4"]).to.never.be.ok()
|
||||
end)
|
||||
|
||||
it("preserves array density after serdes", function()
|
||||
-- This particular change results in a patch that cannot be serialized
|
||||
-- without losing information. Charm resolves this issue internally.
|
||||
local prevState = { "a", "b", "c", "d", "e" }
|
||||
local nextState = { "A", "b", "c", "D" }
|
||||
|
||||
local patches = serdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
for index in next, prevState do
|
||||
expect(applied[index]).to.equal(nextState[index])
|
||||
end
|
||||
|
||||
for index in next, applied do
|
||||
expect(applied[index]).to.equal(nextState[index])
|
||||
end
|
||||
end)
|
||||
|
||||
it("preserves large gaps between keys", function()
|
||||
local prevState = table.create(10000, "foo")
|
||||
local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" })
|
||||
|
||||
local start = os.clock()
|
||||
|
||||
local patches = serdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
assert(os.clock() - start < 1e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`)
|
||||
|
||||
for index in next, prevState do
|
||||
expect(applied[index]).to.equal(nextState[index])
|
||||
end
|
||||
end)
|
||||
|
||||
it("cannot preserve sparse arrays", function()
|
||||
local prevState = {}
|
||||
local nextState = { [2] = "foo" }
|
||||
|
||||
local patches = serdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
-- Not enough information is available to determine whether 'patches'
|
||||
-- should keep the string keys added during remote event serialization.
|
||||
expect(applied["2"]).to.be.ok()
|
||||
expect(applied[2]).to.never.be.ok()
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
local atom = require(script.Parent.Parent.Parent.atom)
|
||||
local sync = require(script.Parent.Parent.Parent.sync)
|
||||
local patch = require(script.Parent.Parent.Parent.sync.patch)
|
||||
local types = require(script.Parent.Parent.Parent.types)
|
||||
|
||||
return function()
|
||||
local target: Player = {} :: any
|
||||
|
||||
it("sends state to new players", function()
|
||||
local a = atom(1)
|
||||
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
||||
local player: Player, payload: types.SyncPayload
|
||||
|
||||
local disconnect = server:connect(function(...)
|
||||
player, payload = ...
|
||||
end)
|
||||
|
||||
server:hydrate(target)
|
||||
|
||||
expect(player).to.equal(target)
|
||||
expect(payload.type).to.equal("init")
|
||||
expect(payload.data.a).to.equal(1)
|
||||
|
||||
disconnect()
|
||||
end)
|
||||
|
||||
it("sends state patches", function()
|
||||
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
|
||||
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
||||
local player: Player, payload: types.SyncPayload
|
||||
|
||||
local disconnect = server:connect(function(...)
|
||||
player, payload = ...
|
||||
end)
|
||||
|
||||
a({ b = 1, c = 5 })
|
||||
server:_sendPatch(target)
|
||||
|
||||
expect(player).to.equal(target)
|
||||
expect(payload.type).to.equal("patch")
|
||||
expect(payload.data.a.b).to.never.be.ok()
|
||||
expect(payload.data.a.c).to.equal(5)
|
||||
|
||||
a({ b = 1, d = 1 })
|
||||
server:_sendPatch(target)
|
||||
|
||||
expect(payload.data.a.b).to.never.be.ok()
|
||||
expect(payload.data.a.c).to.equal(patch.NONE)
|
||||
expect(payload.data.a.d).to.equal(1)
|
||||
|
||||
disconnect()
|
||||
end)
|
||||
|
||||
it("preserves history", function()
|
||||
local a = atom(1)
|
||||
local b = atom(1)
|
||||
local payloads: { types.SyncPayload }
|
||||
|
||||
local server = sync.server({
|
||||
atoms = { a = a, b = b },
|
||||
interval = -1,
|
||||
preserveHistory = true,
|
||||
})
|
||||
|
||||
local disconnect = server:connect(function(_, ...)
|
||||
payloads = { ... }
|
||||
end)
|
||||
|
||||
a(2)
|
||||
b(2)
|
||||
server:_sendPatch(target)
|
||||
|
||||
expect(#payloads).to.equal(1)
|
||||
expect(payloads[1].type).to.equal("patch")
|
||||
expect(payloads[1].data.a).to.equal(2)
|
||||
expect(payloads[1].data.b).to.equal(2)
|
||||
|
||||
a(3)
|
||||
a(4)
|
||||
b(3)
|
||||
server:_sendPatch(target)
|
||||
|
||||
expect(#payloads).to.equal(2)
|
||||
expect(payloads[1].type).to.equal("patch")
|
||||
expect(payloads[1].data.a).to.equal(3)
|
||||
expect(payloads[1].data.b).to.never.be.ok()
|
||||
expect(payloads[2].type).to.equal("patch")
|
||||
expect(payloads[2].data.a).to.equal(4)
|
||||
expect(payloads[2].data.b).to.equal(3)
|
||||
|
||||
disconnect()
|
||||
end)
|
||||
end
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
local atom = require(script.Parent.Parent.Parent.atom)
|
||||
local validate = require(script.Parent.Parent.Parent.sync.validate)
|
||||
|
||||
return function()
|
||||
it("validates inputs", function()
|
||||
local cases: { [string]: { value: any, expected: any } } = {
|
||||
array = { value = { 1, 2, 3 }, expected = true },
|
||||
array_sparse = { value = { 1, nil :: any, 3 }, expected = true },
|
||||
array_sparse_invalid = { value = { [1] = 0, [3] = 0 }, expected = false },
|
||||
|
||||
map_decimal_key = { value = { [1.5] = 0 }, expected = false },
|
||||
map_mixed_key = { value = { [1] = 0, a = 0 }, expected = false },
|
||||
|
||||
number = { value = 1, expected = true },
|
||||
number_infinity = { value = math.huge, expected = false },
|
||||
number_nan = { value = 0 / 0, expected = false },
|
||||
|
||||
nested_atom = { value = atom(0), expected = false },
|
||||
}
|
||||
|
||||
for key, case in next, cases do
|
||||
local success, result = pcall(function()
|
||||
validate(case.value, key)
|
||||
end)
|
||||
|
||||
if case.expected and not success then
|
||||
error(`Expected {key} to be valid, threw {result}`)
|
||||
elseif not case.expected and success then
|
||||
error(`Expected {key} to be invalid, but it was valid`)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
3
src/charm-sync/.luaurc
Normal file
3
src/charm-sync/.luaurc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"languageMode": "strict"
|
||||
}
|
||||
6
src/charm-sync/default.project.json
Normal file
6
src/charm-sync/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "charm-sync",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
16
src/charm-sync/package.json
Normal file
16
src/charm-sync/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "@rbxts/charm-sync",
|
||||
"version": "0.1.0",
|
||||
"description": "Sync server state with clients using Charm",
|
||||
"repository": "littensy/charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rbxts/charm": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
local store = require(script.Parent.Parent.store)
|
||||
local types = require(script.Parent.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type SyncPayload = types.SyncPayload
|
||||
local Charm = require(script.Parent.Parent.Charm)
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
type SyncPayload = types.SyncPayload
|
||||
|
||||
type ClientOptions = {
|
||||
--[=[
|
||||
The atoms to synchronize with the server.
|
||||
]=]
|
||||
atoms: { [string]: Atom<any> },
|
||||
atoms: { [string]: Charm.Atom<any> },
|
||||
--[=[
|
||||
Whether to ignore patches sent before the client has been hydrated.
|
||||
Default is `true`.
|
||||
|
|
@ -52,7 +51,7 @@ local function client(options: ClientOptions): ClientSyncer
|
|||
|
||||
hydrated = true
|
||||
|
||||
store.batch(function()
|
||||
Charm.batch(function()
|
||||
for key, state in next, payload.data do
|
||||
local atom = atoms[key]
|
||||
|
||||
163
src/charm-sync/src/index.d.ts
vendored
Normal file
163
src/charm-sync/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { Atom, Molecule } from "@rbxts/charm";
|
||||
|
||||
export = CharmSync;
|
||||
export as namespace CharmSync;
|
||||
|
||||
type Cleanup = () => void;
|
||||
|
||||
declare namespace CharmSync {
|
||||
type AtomMap = Record<string, Atom<any>>;
|
||||
|
||||
type MoleculeMap = Record<string, Molecule<any>>;
|
||||
|
||||
/**
|
||||
* Recursively infers the type of the state produced by a map of molecules.
|
||||
*/
|
||||
type StateOfMap<T> = {
|
||||
[P in keyof T]: T[P] extends Molecule<infer State> ? State : never;
|
||||
};
|
||||
|
||||
/**
|
||||
* A special value that denotes the absence of a value. Used to represent
|
||||
* removed values in patches.
|
||||
*/
|
||||
interface None {
|
||||
readonly __none: "__none";
|
||||
}
|
||||
|
||||
type MaybeNone<T> = undefined extends T ? None : never;
|
||||
|
||||
/**
|
||||
* Creates a `ClientSyncer` object that receives patches from the server and
|
||||
* applies them to the local state.
|
||||
*
|
||||
* @client
|
||||
* @param options The atoms to synchronize with the server.
|
||||
* @returns A `ClientSyncer` object.
|
||||
*/
|
||||
function client<Atoms extends AtomMap>(options: ClientOptions<Atoms>): ClientSyncer<Atoms>;
|
||||
|
||||
/**
|
||||
* Creates a `ServerSyncer` object that sends patches to the client and
|
||||
* hydrates the client's state.
|
||||
*
|
||||
* @server
|
||||
* @param options The atoms to synchronize with the client.
|
||||
* @returns A `ServerSyncer` object.
|
||||
*/
|
||||
function server<Molecules extends MoleculeMap>(options: ServerOptions<Molecules>): ServerSyncer<Molecules>;
|
||||
|
||||
/**
|
||||
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||
* removed from the state when the patch is applied.
|
||||
*
|
||||
* @param value The value to check.
|
||||
* @returns `true` if the value is `None`, otherwise `false`.
|
||||
*/
|
||||
function isNone(value: unknown): value is None;
|
||||
|
||||
type DataTypes = {
|
||||
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
||||
};
|
||||
|
||||
/**
|
||||
* A type that should not be made partial in patches.
|
||||
*/
|
||||
type DataType = DataTypes[keyof DataTypes];
|
||||
|
||||
/**
|
||||
* A partial patch that can be applied to the state to update it. Represents
|
||||
* the difference between the current state and the next state.
|
||||
*
|
||||
* If a value was removed, it is replaced with `None`. This can be checked
|
||||
* using the `sync.isNone` function.
|
||||
*/
|
||||
type SyncPatch<State> =
|
||||
| MaybeNone<State>
|
||||
| (State extends ReadonlyMap<infer K, infer V> | Map<infer K, infer V>
|
||||
? ReadonlyMap<K, SyncPatch<V> | None>
|
||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||
? ReadonlyMap<T, true | None>
|
||||
: State extends readonly (infer T)[]
|
||||
? readonly (SyncPatch<T> | None | undefined)[]
|
||||
: State extends DataType
|
||||
? State
|
||||
: State extends object
|
||||
? { readonly [P in keyof State]?: SyncPatch<State[P]> }
|
||||
: State);
|
||||
|
||||
/**
|
||||
* A payload that can be sent from the server to the client to synchronize
|
||||
* state between the two.
|
||||
*/
|
||||
type SyncPayload<Molecules extends MoleculeMap> =
|
||||
| { type: "init"; data: StateOfMap<Molecules> }
|
||||
| { type: "patch"; data: SyncPatch<StateOfMap<Molecules>> };
|
||||
|
||||
interface ClientOptions<Atoms extends AtomMap> {
|
||||
/**
|
||||
* The atoms to synchronize with the server.
|
||||
*/
|
||||
atoms: Atoms;
|
||||
/**
|
||||
* Whether to ignore patches sent before the client has been hydrated.
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
ignoreUnhydrated?: boolean;
|
||||
}
|
||||
|
||||
interface ServerOptions<Molecules extends MoleculeMap> {
|
||||
/**
|
||||
* The atoms to synchronize with the client.
|
||||
*/
|
||||
atoms: Molecules;
|
||||
/**
|
||||
* The interval at which to send patches to the client, in seconds.
|
||||
* Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||
* negative value to disable automatic syncing.
|
||||
*/
|
||||
interval?: number;
|
||||
/**
|
||||
* Whether the history of state changes since the client's last update
|
||||
* should be preserved. This is useful for values that change multiple times
|
||||
* per frame, where each individual change is important. Defaults to `false`.
|
||||
*
|
||||
* If `true`, the broadcaster will send a list of payloads to the client
|
||||
* instead of a single payload. The client will apply each payload in order
|
||||
* to reconstruct the state's changes over time.
|
||||
*/
|
||||
preserveHistory?: boolean;
|
||||
}
|
||||
|
||||
interface ClientSyncer<Atoms extends AtomMap> {
|
||||
/**
|
||||
* Applies a patch or initializes the state of the atoms with the given
|
||||
* payload from the server.
|
||||
*
|
||||
* @param payloads The patches or hydration payloads to apply.
|
||||
*/
|
||||
sync(...payloads: SyncPayload<Atoms>[]): void;
|
||||
}
|
||||
|
||||
interface ServerSyncer<Molecules extends MoleculeMap> {
|
||||
/**
|
||||
* Sets up a subscription to each atom that schedules a patch to be sent to
|
||||
* the client whenever the state changes. When a change occurs, the `callback`
|
||||
* is called with the player and the payloads to send.
|
||||
*
|
||||
* Note that a `payload` object should not be mutated. If you need to modify
|
||||
* a payload, apply the changes to a copy of the object.
|
||||
*
|
||||
* @param callback The function to call when the state changes.
|
||||
* @returns A cleanup function that unsubscribes all listeners.
|
||||
*/
|
||||
connect(callback: (player: Player, ...payloads: SyncPayload<Molecules>[]) => void): Cleanup;
|
||||
/**
|
||||
* Hydrates the client's state with the server's state. This should be
|
||||
* called when a player joins the game and requires the server's state.
|
||||
*
|
||||
* @param player The player to hydrate.
|
||||
*/
|
||||
hydrate(player: Player): void;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
local client = require(script.client)
|
||||
local patch = require(script.patch)
|
||||
local server = require(script.server)
|
||||
local types = require(script.types)
|
||||
|
||||
export type SyncPayload = types.SyncPayload
|
||||
|
||||
--[=[
|
||||
Synchronizes state between the client and server. The server sends patches
|
||||
|
|
@ -7,7 +7,7 @@ local RunService = game:GetService("RunService")
|
|||
@param seconds The interval in seconds.
|
||||
@return A function that cancels the interval.
|
||||
]=]
|
||||
local function setInterval(callback: () -> (), seconds: number): () -> ()
|
||||
local function interval(callback: () -> (), seconds: number): () -> ()
|
||||
if seconds < 0 then
|
||||
return function() end
|
||||
end
|
||||
|
|
@ -27,4 +27,4 @@ local function setInterval(callback: () -> (), seconds: number): () -> ()
|
|||
end
|
||||
end
|
||||
|
||||
return setInterval
|
||||
return interval
|
||||
|
|
@ -4,7 +4,7 @@ local validate = require(script.Parent.validate)
|
|||
A special symbol that denotes the absence of a value. Used to represent
|
||||
deleted values in patches.
|
||||
]=]
|
||||
local NONE = { __none = "__none" }
|
||||
local None = { __none = "__none" }
|
||||
|
||||
local function isNone(value: any): boolean
|
||||
return type(value) == "table" and value.__none == "__none"
|
||||
|
|
@ -46,7 +46,7 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any })
|
|||
if previous == current then
|
||||
patches[key] = nil
|
||||
elseif current == nil then
|
||||
patches[key] = NONE
|
||||
patches[key] = None
|
||||
elseif type(previous) == "table" and type(current) == "table" then
|
||||
patches[key] = diff(previous, current)
|
||||
end
|
||||
|
|
@ -97,7 +97,7 @@ local function apply(state: any, patches: any): any
|
|||
end
|
||||
|
||||
return {
|
||||
NONE = NONE,
|
||||
NONE = None,
|
||||
isNone = isNone,
|
||||
diff = diff,
|
||||
apply = apply,
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
local Players = game:GetService("Players")
|
||||
|
||||
local subscribe = require(script.Parent.Parent.subscribe)
|
||||
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 Charm = require(script.Parent.Parent.Charm)
|
||||
local interval = require(script.Parent.interval)
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
type SyncPayload = types.SyncPayload
|
||||
|
||||
type ServerOptions = {
|
||||
--[=[
|
||||
The atoms to synchronize with the client.
|
||||
]=]
|
||||
atoms: { [string]: Atom<any> },
|
||||
atoms: { [string]: Charm.Atom<any> },
|
||||
--[=[
|
||||
The interval at which to send patches to the client, in seconds.
|
||||
Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||
|
|
@ -103,7 +102,7 @@ local function server(options: ServerOptions): ServerSyncer
|
|||
-- Populate the snapshot with the initial state of each atom.
|
||||
-- Subscribe to each atom and update the state when it changes.
|
||||
for key, atom in next, options.atoms do
|
||||
subscriptions[key] = subscribe(atom, function(current, previous)
|
||||
subscriptions[key] = Charm.subscribe(atom, function(current, previous)
|
||||
pushSnapshot(key, current, previous)
|
||||
changed = true
|
||||
end)
|
||||
|
|
@ -111,7 +110,7 @@ local function server(options: ServerOptions): ServerSyncer
|
|||
|
||||
snapshots[1] = createSnapshot()
|
||||
|
||||
local disconnect = setInterval(function()
|
||||
local disconnect = interval(function()
|
||||
if not changed then
|
||||
return
|
||||
end
|
||||
|
|
@ -187,13 +186,13 @@ local function server(options: ServerOptions): ServerSyncer
|
|||
|
||||
-- Subscribe to each atom and update the state when one changes.
|
||||
for key, atom in next, options.atoms do
|
||||
subscriptions[key] = subscribe(atom, function(state)
|
||||
subscriptions[key] = Charm.subscribe(atom, function(state)
|
||||
currentSnapshot[key] = state
|
||||
changed = true
|
||||
end)
|
||||
end
|
||||
|
||||
local disconnect = setInterval(function()
|
||||
local disconnect = interval(function()
|
||||
if not changed then
|
||||
return
|
||||
end
|
||||
10
src/charm-sync/src/types.luau
Normal file
10
src/charm-sync/src/types.luau
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
--[=[
|
||||
A payload that can be sent from the server to the client to synchronize
|
||||
state between the two.
|
||||
]=]
|
||||
export type SyncPayload = {
|
||||
type: "patch" | "init",
|
||||
data: { [any]: any },
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
local store = require(script.Parent.Parent.store)
|
||||
local Charm = require(script.Parent.Parent.Charm)
|
||||
|
||||
local SAFE_KEYS = { string = true, number = true }
|
||||
local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true }
|
||||
|
|
@ -47,7 +47,7 @@ local function validate(value: any, key: any)
|
|||
elseif typeOfValue == "table" then
|
||||
if getmetatable(value) ~= nil then
|
||||
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
|
||||
elseif store.isAtom(value) then
|
||||
elseif Charm.isAtom(value) then
|
||||
error(`Cannot sync nested atoms! Got atom at key '{key}'`)
|
||||
elseif isUnsafeTable(value) then
|
||||
error(
|
||||
7
src/charm-sync/wally.toml
Normal file
7
src/charm-sync/wally.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "littensy/charm-sync"
|
||||
version = "0.1.0"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
|
||||
[dependencies]
|
||||
3
src/charm/.luaurc
Normal file
3
src/charm/.luaurc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"languageMode": "strict"
|
||||
}
|
||||
6
src/charm/default.project.json
Normal file
6
src/charm/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
13
src/charm/package.json
Normal file
13
src/charm/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "@rbxts/charm",
|
||||
"version": "0.8.0",
|
||||
"description": "An atomic state management library for Roblox",
|
||||
"repository": "littensy/charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
}
|
||||
}
|
||||
172
src/index.d.ts → src/charm/src/index.d.ts
vendored
172
src/index.d.ts → src/charm/src/index.d.ts
vendored
|
|
@ -42,16 +42,6 @@ declare namespace Charm {
|
|||
*/
|
||||
type StateOf<T> = T extends Molecule<infer State> ? State : never;
|
||||
|
||||
/**
|
||||
* Recursively infers the type of the state produced by a map of molecules.
|
||||
*/
|
||||
type StateOfMap<T> = {
|
||||
[P in keyof T]: T[P] extends Molecule<infer State> ? State : never;
|
||||
};
|
||||
|
||||
type AtomMap = Record<string, Atom<any>>;
|
||||
type MoleculeMap = Record<string, Molecule<any>>;
|
||||
|
||||
interface AtomOptions<State> {
|
||||
/**
|
||||
* A function that determines whether the state has changed. By default,
|
||||
|
|
@ -189,166 +179,4 @@ declare namespace Charm {
|
|||
molecule: Molecule<AnyMap<K0, V0>>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): Molecule<ReadonlyMap<K1, V1>>;
|
||||
|
||||
/**
|
||||
* A hook that subscribes to changes in the given atom or molecule. The
|
||||
* component is re-rendered whenever the state changes.
|
||||
*
|
||||
* If the `dependencies` array is provided, the subscription to the atom or
|
||||
* molecule is re-created whenever the dependencies change. Otherwise, the
|
||||
* subscription is created once when the component is mounted.
|
||||
*
|
||||
* @param molecule The atom or molecule to subscribe to.
|
||||
* @param dependencies An array of values that the subscription depends on.
|
||||
* @returns The current state.
|
||||
*/
|
||||
function useAtom<State>(molecule: Molecule<State>, dependencies?: unknown[]): State;
|
||||
|
||||
/**
|
||||
* Synchronizes state between the client and server. The server sends patches
|
||||
* to the client, which applies them to its local state.
|
||||
*/
|
||||
const sync: {
|
||||
/**
|
||||
* Creates a `ClientSyncer` object that receives patches from the server and
|
||||
* applies them to the local state.
|
||||
*
|
||||
* @client
|
||||
* @param options The atoms to synchronize with the server.
|
||||
* @returns A `ClientSyncer` object.
|
||||
*/
|
||||
client: <Atoms extends AtomMap>(options: ClientOptions<Atoms>) => ClientSyncer<Atoms>;
|
||||
/**
|
||||
* Creates a `ServerSyncer` object that sends patches to the client and
|
||||
* hydrates the client's state.
|
||||
*
|
||||
* @server
|
||||
* @param options The atoms to synchronize with the client.
|
||||
* @returns A `ServerSyncer` object.
|
||||
*/
|
||||
server: <Molecules extends MoleculeMap>(options: ServerOptions<Molecules>) => ServerSyncer<Molecules>;
|
||||
/**
|
||||
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||
* removed from the state when the patch is applied.
|
||||
*
|
||||
* @param value The value to check.
|
||||
* @returns `true` if the value is `None`, otherwise `false`.
|
||||
*/
|
||||
isNone: (value: unknown) => value is None;
|
||||
};
|
||||
|
||||
/**
|
||||
* A special value that denotes the absence of a value. Used to represent
|
||||
* removed values in patches.
|
||||
*/
|
||||
interface None {
|
||||
readonly __none: "__none";
|
||||
}
|
||||
|
||||
type MaybeNone<T> = undefined extends T ? None : never;
|
||||
|
||||
type DataTypes = {
|
||||
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
||||
};
|
||||
|
||||
/**
|
||||
* A type that should not be made partial in patches.
|
||||
*/
|
||||
type DataType = DataTypes[keyof DataTypes];
|
||||
|
||||
/**
|
||||
* A partial patch that can be applied to the state to update it. Represents
|
||||
* the difference between the current state and the next state.
|
||||
*
|
||||
* If a value was removed, it is replaced with `None`. This can be checked
|
||||
* using the `sync.isNone` function.
|
||||
*/
|
||||
type SyncPatch<State> =
|
||||
| MaybeNone<State>
|
||||
| (State extends ReadonlyMap<infer K, infer V> | Map<infer K, infer V>
|
||||
? ReadonlyMap<K, SyncPatch<V> | None>
|
||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||
? ReadonlyMap<T, true | None>
|
||||
: State extends readonly (infer T)[]
|
||||
? readonly (SyncPatch<T> | None | undefined)[]
|
||||
: State extends DataType
|
||||
? State
|
||||
: State extends object
|
||||
? { readonly [P in keyof State]?: SyncPatch<State[P]> }
|
||||
: State);
|
||||
|
||||
/**
|
||||
* A payload that can be sent from the server to the client to synchronize
|
||||
* state between the two.
|
||||
*/
|
||||
type SyncPayload<Molecules extends MoleculeMap> =
|
||||
| { type: "init"; data: StateOfMap<Molecules> }
|
||||
| { type: "patch"; data: SyncPatch<StateOfMap<Molecules>> };
|
||||
|
||||
interface ClientOptions<Atoms extends AtomMap> {
|
||||
/**
|
||||
* The atoms to synchronize with the server.
|
||||
*/
|
||||
atoms: Atoms;
|
||||
/**
|
||||
* Whether to ignore patches sent before the client has been hydrated.
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
ignoreUnhydrated?: boolean;
|
||||
}
|
||||
|
||||
interface ServerOptions<Molecules extends MoleculeMap> {
|
||||
/**
|
||||
* The atoms to synchronize with the client.
|
||||
*/
|
||||
atoms: Molecules;
|
||||
/**
|
||||
* The interval at which to send patches to the client, in seconds.
|
||||
* Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||
* negative value to disable automatic syncing.
|
||||
*/
|
||||
interval?: number;
|
||||
/**
|
||||
* Whether the history of state changes since the client's last update
|
||||
* should be preserved. This is useful for values that change multiple times
|
||||
* per frame, where each individual change is important. Defaults to `false`.
|
||||
*
|
||||
* If `true`, the broadcaster will send a list of payloads to the client
|
||||
* instead of a single payload. The client will apply each payload in order
|
||||
* to reconstruct the state's changes over time.
|
||||
*/
|
||||
preserveHistory?: boolean;
|
||||
}
|
||||
|
||||
interface ClientSyncer<Atoms extends AtomMap> {
|
||||
/**
|
||||
* Applies a patch or initializes the state of the atoms with the given
|
||||
* payload from the server.
|
||||
*
|
||||
* @param payloads The patches or hydration payloads to apply.
|
||||
*/
|
||||
sync(...payloads: SyncPayload<Atoms>[]): void;
|
||||
}
|
||||
|
||||
interface ServerSyncer<Molecules extends MoleculeMap> {
|
||||
/**
|
||||
* Sets up a subscription to each atom that schedules a patch to be sent to
|
||||
* the client whenever the state changes. When a change occurs, the `callback`
|
||||
* is called with the player and the payloads to send.
|
||||
*
|
||||
* Note that a `payload` object should not be mutated. If you need to modify
|
||||
* a payload, apply the changes to a copy of the object.
|
||||
*
|
||||
* @param callback The function to call when the state changes.
|
||||
* @returns A cleanup function that unsubscribes all listeners.
|
||||
*/
|
||||
connect(callback: (player: Player, ...payloads: SyncPayload<Molecules>[]) => void): Cleanup;
|
||||
/**
|
||||
* Hydrates the client's state with the server's state. This should be
|
||||
* called when a player joins the game and requires the server's state.
|
||||
*
|
||||
* @param player The player to hydrate.
|
||||
*/
|
||||
hydrate(player: Player): void;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,10 @@
|
|||
_G[script.modules.Promise] = _G[script]
|
||||
|
||||
local atom = require(script.atom)
|
||||
local computed = require(script.computed)
|
||||
local effect = require(script.effect)
|
||||
local mapped = require(script.mapped)
|
||||
local observe = require(script.observe)
|
||||
local useAtom = require(script.react.useAtom)
|
||||
local store = require(script.store)
|
||||
local subscribe = require(script.subscribe)
|
||||
local sync = require(script.sync)
|
||||
local types = require(script.types)
|
||||
|
||||
export type Atom<State> = types.Atom<State>
|
||||
|
|
@ -21,12 +17,10 @@ return {
|
|||
effect = effect,
|
||||
mapped = mapped,
|
||||
observe = observe,
|
||||
useAtom = useAtom,
|
||||
subscribe = subscribe,
|
||||
batch = store.batch,
|
||||
capture = store.capture,
|
||||
isAtom = store.isAtom,
|
||||
notify = store.notify,
|
||||
peek = store.peek,
|
||||
subscribe = subscribe,
|
||||
sync = sync,
|
||||
}
|
||||
7
src/charm/wally.toml
Normal file
7
src/charm/wally.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "littensy/charm"
|
||||
version = "0.8.0"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
local Package = script.Parent.Parent
|
||||
local Index = script:FindFirstAncestor("_Index")
|
||||
local Packages = if Package.Parent:FindFirstChild("React")
|
||||
then Package.Parent
|
||||
elseif Index then Index.Parent
|
||||
else Package.Parent.Parent:FindFirstChild("ReactLua")
|
||||
|
||||
type React = typeof(require(Package.Parent.React))
|
||||
|
||||
local React: React
|
||||
|
||||
return function()
|
||||
if not React then
|
||||
React = Packages and require(Packages.React) :: React
|
||||
end
|
||||
return React
|
||||
end
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
local Package = script.Parent.Parent
|
||||
local Packages = if Package.Parent:FindFirstChild("ReactRoblox")
|
||||
then Package.Parent
|
||||
else Package.Parent.Parent:FindFirstChild("ReactLua")
|
||||
|
||||
type ReactRoblox = typeof(require(Package.Parent.ReactRoblox))
|
||||
|
||||
-- It's safe to require this module since it's only used in unit tests.
|
||||
return (if Packages then require(Packages.ReactRoblox) else nil) :: ReactRoblox
|
||||
3
src/react/.luaurc
Normal file
3
src/react/.luaurc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"languageMode": "strict"
|
||||
}
|
||||
6
src/react/default.project.json
Normal file
6
src/react/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "react-charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
19
src/react/package.json
Normal file
19
src/react/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@rbxts/react-charm",
|
||||
"version": "0.1.0",
|
||||
"description": "React bindings for Charm",
|
||||
"repository": "littensy/charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rbxts/charm": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/react": "*"
|
||||
}
|
||||
}
|
||||
15
src/react/src/index.d.ts
vendored
Normal file
15
src/react/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Molecule } from "@rbxts/charm";
|
||||
|
||||
/**
|
||||
* A hook that subscribes to changes in the given atom or molecule. The
|
||||
* component is re-rendered whenever the state changes.
|
||||
*
|
||||
* If the `dependencies` array is provided, the subscription to the atom or
|
||||
* molecule is re-created whenever the dependencies change. Otherwise, the
|
||||
* subscription is created once when the component is mounted.
|
||||
*
|
||||
* @param molecule The atom or molecule to subscribe to.
|
||||
* @param dependencies An array of values that the subscription depends on.
|
||||
* @returns The current state.
|
||||
*/
|
||||
export function useAtom<State>(molecule: Molecule<State>, dependencies?: unknown[]): State;
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
local requireReact = require(script.Parent.Parent.modules.React)
|
||||
local subscribe = require(script.Parent.Parent.subscribe)
|
||||
local types = require(script.Parent.Parent.types)
|
||||
type Molecule<T> = types.Molecule<T>
|
||||
local Charm = require(script.Parent.Charm)
|
||||
local React = require(script.Parent.React)
|
||||
|
||||
--[=[
|
||||
A hook that subscribes to changes in the given atom or molecule. The
|
||||
|
|
@ -15,17 +13,17 @@ type Molecule<T> = types.Molecule<T>
|
|||
@param dependencies An array of values that the subscription depends on.
|
||||
@return The current state.
|
||||
]=]
|
||||
local function useAtom<State>(molecule: Molecule<State>, dependencies: { any }?): State
|
||||
local React = requireReact()
|
||||
|
||||
local function useAtom<State>(molecule: Charm.Molecule<State>, dependencies: { any }?): State
|
||||
local state, setState = React.useState(molecule)
|
||||
|
||||
React.useEffect(function()
|
||||
setState(molecule())
|
||||
return subscribe(molecule, setState)
|
||||
return Charm.subscribe(molecule, setState)
|
||||
end, dependencies or {})
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
return useAtom
|
||||
return {
|
||||
useAtom = useAtom,
|
||||
}
|
||||
7
src/react/wally.toml
Normal file
7
src/react/wally.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "littensy/react-charm"
|
||||
version = "0.1.0"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
--[=[
|
||||
Trigger a garbage collection cycle by allocating a large amount of junk memory
|
||||
and then waiting for the next frame.
|
||||
]=]
|
||||
local function collect()
|
||||
for _ = 1, 1e4 do
|
||||
local _ = table.create(1e3)
|
||||
end
|
||||
task.wait()
|
||||
end
|
||||
|
||||
return collect
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
--[=[
|
||||
Counts the number of elements in a table.
|
||||
|
||||
@param object The table to count.
|
||||
@return The number of elements in the table.
|
||||
]=]
|
||||
local function count(object: any): number
|
||||
local count = 0
|
||||
for _ in next, object do
|
||||
count += 1
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
return count
|
||||
3
src/vide/.luaurc
Normal file
3
src/vide/.luaurc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"languageMode": "strict"
|
||||
}
|
||||
6
src/vide/default.project.json
Normal file
6
src/vide/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "vide-charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
19
src/vide/package.json
Normal file
19
src/vide/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@rbxts/vide-charm",
|
||||
"version": "0.1.0",
|
||||
"description": "Vide bindings for Charm",
|
||||
"repository": "littensy/charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rbxts/charm": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/vide": "*"
|
||||
}
|
||||
}
|
||||
9
src/vide/src/index.d.ts
vendored
Normal file
9
src/vide/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Molecule } from "@rbxts/charm";
|
||||
|
||||
/**
|
||||
* Subscribes to the state of an atom and returns a Vide source.
|
||||
*
|
||||
* @param molecule The atom or molecule to subscribe to.
|
||||
* @returns The reactive source.
|
||||
*/
|
||||
export function useAtom<T>(atom: Molecule<T>): () => T;
|
||||
23
src/vide/src/init.luau
Normal file
23
src/vide/src/init.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
local Charm = require(script.Parent.Charm)
|
||||
local Vide = require(script.Parent.Vide)
|
||||
|
||||
--[=[
|
||||
Subscribes to the state of an atom and returns a Vide source.
|
||||
|
||||
@param molecule The atom or molecule to subscribe to.
|
||||
@return The reactive source.
|
||||
]=]
|
||||
local function useAtom<State>(molecule: () -> State): State
|
||||
local state = Vide.source(molecule())
|
||||
local unsubscribe = Charm.subscribe(molecule, function(value)
|
||||
task.spawn(state, value)
|
||||
end)
|
||||
|
||||
Vide.cleanup(unsubscribe)
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
return {
|
||||
useAtom = useAtom,
|
||||
}
|
||||
7
src/vide/wally.toml
Normal file
7
src/vide/wally.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "littensy/vide-charm"
|
||||
version = "0.1.0"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
|
||||
[dependencies]
|
||||
Loading…
Add table
Add a link
Reference in a new issue