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