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:
richard 2024-03-25 19:53:28 -07:00 • committed by GitHub
parent 4d4979daa9
commit 33ecae2f50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 577 additions and 729 deletions

View file

@ -1,6 +1,6 @@
<p align="center">
<p align="center">
<img width="150" height="150" src="images/logo.png" alt="Logo">
<img width="150" height="150" src="https://raw.githubusercontent.com/littensy/charm/master/images/logo.png" alt="Logo">
</p>
<h1 align="center"><b>Charm</b></h1>
<p align="center">
@ -45,37 +45,23 @@ Add `littensy/charm` to your `wally.toml` file.
Charm = "littensy/charm@VERSION"
```
## 📚 Documentation
## 📚 API Reference
### Core
### Charm
#### `atom(state)`
#### `derive(...atoms, combiner)`
#### `effect(callback)`
#### `subscribe(atom, listener)`
#### `derive(atom, equals?)`
#### `observe(atom, observer)`
#### `mapAtom(atom, mapper)`
#### `map(atom, mapper)`
### Atom
#### `Atom(state?)`
#### `Atom.get(selector?)`
#### `Atom.set(state)`
#### `Atom.memo(areEqual)`
#### `Atom.readonly()`
#### `Atom.writable(onSet?)`
#### `Atom.named(label)`
#### `Atom.unmount()`
#### `unwrap(atom)`
### React
@ -93,7 +79,7 @@ Charm = "littensy/charm@VERSION"
#### `sync.server(options)`
#### `Server.onSync(callback)`
#### `Server.connect(callback)`
#### `Server.hydrate(player)`
@ -102,10 +88,10 @@ Charm = "littensy/charm@VERSION"
### Counter atom
```ts
import { atom, derive, subscribe } from "@rbxts/charm";
import { atom, subscribe } from "@rbxts/charm";
const counterAtom = atom(0);
const doubleCounterAtom = derive(counterAtom, (counter) => counter * 2);
const doubleCounterAtom = () => counterAtom() * 2;
subscribe(doubleCounterAtom, (value) => {
print(value);
@ -137,12 +123,6 @@ function Counter() {
}
```
## 🙋 Concerns
In Jotai, `atom()` returns an [atom config](https://jotai.org/docs/core/atom), and its state is managed entirely by the Provider. However, in Charm, `atom()` returns something closer to a store, and its state is managed by the atom itself.
Charm does this for simplicity in use cases outside of React, but this might go against Jotai's focus on pure functional programming. More insight is needed to determine if this is a good idea.
---
<p align="center">

View file

@ -26,9 +26,7 @@
"homepage": "https://github.com/littensy/charm",
"files": [
"src",
"default.project.json",
"images/logo.png",
"!src/**/*.spec.ts"
"!src/**/*.spec.luau"
],
"publishConfig": {
"access": "public"

View file

@ -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

View file

@ -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,
}

View 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

View file

@ -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

View file

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

View file

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

View file

@ -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

View file

@ -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()

View file

@ -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> = {

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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
View 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

View file

@ -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
View 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
View file

@ -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>>;
}
}

View file

@ -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,

View file

@ -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
View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -1,7 +1,8 @@
local React = require(script.Parent.Parent.modules.React)
local requireReact = require(script.Parent.Parent.modules.React)
local subscribe = require(script.Parent.Parent.subscribe)
local atom = require(script.Parent.Parent.atom)
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

View file

@ -1,13 +1,13 @@
local React = require(script.Parent.Parent.modules.React)
local requireReact = require(script.Parent.Parent.modules.React)
local atom = require(script.Parent.Parent.atom)
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
View 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,
}

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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",

View file

@ -1,6 +0,0 @@
export type SyncPayload = {
type: "patch" | "set",
data: { [any]: any },
}
return nil

16
src/types.luau Normal file
View 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
View 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

View file

@ -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,
}