feat: allow conditional dependencies (#4)

* feat: allow conditional dependencies

* docs: remove conditional deps warnings

* fix: allow nested captures

* docs: add dev flag

* fix: `try()` uses pcall if passed `finally()`

* docs: interval is optional

* docs: update readme

* docs: add client/server tags
This commit is contained in:
richard 2024-06-11 17:13:43 -07:00 • committed by GitHub
parent fbc86d22c6
commit a90d131fed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 290 additions and 98 deletions

View file

@ -24,7 +24,7 @@
- ⚛️ **Manage state with _atoms_.** Decompose state into small, distinct containers called _atoms_, as opposed to combining them into a single store. - ⚛️ **Manage state with _atoms_.** Decompose state into small, distinct containers called _atoms_, as opposed to combining them into a single store.
- 💪 **Minimal, yet powerful.** No concept of actions or middleware — write simple functions to read from and write to state. - 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state.
- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness. - 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness.
@ -51,6 +51,23 @@ Charm = "littensy/charm@VERSION"
--- ---
## 🐛 Debugging
Charm provides a debug mode to help you identify potential bugs in your project. To enable debug mode, set the global `_G.__DEV__` flag to `true` at the entry point of your project.
Enabling `__DEV__` adds a few helpful features:
- Better error handling for molecules, listeners, and batched functions:
- Errors provide the function's name and line number.
- Yielding in these functions will throw an error.
- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client.
Enabling debug mode in unit tests, storybooks, and other development environments can help you catch potential issues early. However, remember to turn off debug mode in production to avoid the performance overhead.
---
## 📚 Reference ## 📚 Reference
### `atom(state, options?)` ### `atom(state, options?)`
@ -144,10 +161,6 @@ effect(() => {
`effect` returns a cleanup function. `effect` returns a cleanup function.
#### Caveats
- Dependencies are captured when the effect is created, but not when it re-runs. This means that atoms referenced conditionally might be "skipped" in the dependency list.
--- ---
### `computed(molecule, options?)` ### `computed(molecule, options?)`
@ -379,7 +392,7 @@ remotes.requestState.connect((player) => {
- `atoms`: An object containing the atoms to sync. The keys should match the keys on the client. - `atoms`: An object containing the atoms to sync. The keys should match the keys on the client.
- `interval`: The interval at which to send state updates to clients. Defaults to `0`, meaning updates are sent on the next frame. - **optional** `interval`: The interval at which to send state updates to clients. Defaults to `0`, meaning updates are sent on the next frame.
#### Returns #### Returns

View file

@ -7,34 +7,23 @@ local atom = require(Package.atom)
local computed = require(Package.computed) local computed = require(Package.computed)
local function parameterGenerator() local function parameterGenerator()
return atom({ count = 0 }) return atom(0)
end end
local benchmarks = { local benchmarks = {
atom = function(_, source: any) atom = function(_, source: any)
for _ = 1, 500 do for _ = 1, 500 do
source({ count = math.random(1, 2) }) source(math.random(1, 2))
end end
end, end,
computed = function(_, source: any) computed = function(_, source: any)
computed(function() computed(function()
return { count = 2 * source().count } return 2 * source()
end) end)
for _ = 1, 500 do
source({ count = math.random(1, 2) })
end
end,
computed_nested = function(_, source: any)
local double = computed(function()
return { count = 2 * source().count }
end)
computed(function()
return { count = 2 * double().count }
end)
for _ = 1, 500 do for _ = 1, 500 do
source({ count = math.random(1, 2) }) source(math.random(1, 2))
end end
end, end,
} }

View file

@ -2,21 +2,47 @@ local atom = require(script.Parent.Parent.atom)
local store = require(script.Parent.Parent.store) local store = require(script.Parent.Parent.store)
return function() return function()
it("gracefully handles yielding", function()
expect(function()
store.capture(function()
task.wait(10)
return 1
end)
end).to.throw()
end)
it("captures atoms from a function", function() it("captures atoms from a function", function()
local a, b, c = atom(1), atom(1), atom(2) local a, b, c = atom(1), atom(1), atom(2)
local captured, result = store.capture(function() local deps, result = store.capture(function()
return a() + b() return a() + b()
end) end)
expect(captured[a]).to.be.ok() expect(deps[a]).to.be.ok()
expect(captured[b]).to.be.ok() expect(deps[b]).to.be.ok()
expect(captured[c]).to.never.be.ok() expect(deps[c]).to.never.be.ok()
expect(result).to.equal(2) expect(result).to.equal(2)
end) end)
it("captures an atom by itself", function() it("captures an atom by itself", function()
local a = atom(1) local a = atom(1)
local captured, result = store.capture(a) local deps, result = store.capture(a)
expect(captured[a]).to.be.ok() expect(deps[a]).to.be.ok()
expect(result).to.equal(1) expect(result).to.equal(1)
end) end)
it("allows nested captures", function()
local a = atom(1)
local b = atom(1)
local inner
local outer = store.capture(function()
inner = store.capture(function()
return b()
end)
return a()
end)
assert(outer[a], "outer did not capture a")
assert(outer[b], "outer did not capture b")
assert(inner[a] == nil, "inner captured a")
assert(inner[b], "inner did not capture b")
end)
end end

View file

@ -68,4 +68,35 @@ return function()
assert(count(store.listeners[source]) == 0, "nested computed atom did not gc") assert(count(store.listeners[source]) == 0, "nested computed atom did not gc")
end) end)
it("tracks conditional dependencies", function()
local condition = atom(false)
local a = atom(0)
local b = atom(1)
local reruns = 0
local current
-- assign variable to avoid gc
local _ = computed(function()
reruns += 1
current = if condition() then a() else b()
return current
end)
expect(reruns).to.equal(1)
expect(current).to.equal(1)
condition(true)
expect(reruns).to.equal(2)
expect(current).to.equal(0)
a(1)
b(2)
expect(reruns).to.equal(3)
expect(current).to.equal(1)
condition(false)
expect(reruns).to.equal(4)
expect(current).to.equal(2)
end)
end end

View file

@ -43,4 +43,33 @@ return function()
unsubscribe() unsubscribe()
expect(cleanups).to.equal(2) expect(cleanups).to.equal(2)
end) end)
it("tracks conditional dependencies", function()
local condition = atom(false)
local a = atom(0)
local b = atom(1)
local reruns = 0
local current
effect(function()
reruns += 1
current = if condition() then a() else b()
end)
expect(reruns).to.equal(1)
expect(current).to.equal(1)
condition(true)
expect(reruns).to.equal(2)
expect(current).to.equal(0)
a(1)
b(2)
expect(reruns).to.equal(3)
expect(current).to.equal(1)
condition(false)
expect(reruns).to.equal(4)
expect(current).to.equal(2)
end)
end end

View file

@ -4,12 +4,12 @@ local store = require(script.Parent.Parent.store)
return function() return function()
it("calls an atom without capturing it", function() it("calls an atom without capturing it", function()
local a, b, c = atom(1), atom(1), atom(1) local a, b, c = atom(1), atom(1), atom(1)
local captured, result = store.capture(function() local deps, result = store.capture(function()
return a() + store.peek(b) + c() return a() + store.peek(b) + c()
end) end)
expect(captured[a]).to.be.ok() expect(deps[a]).to.be.ok()
expect(captured[b]).to.never.be.ok() expect(deps[b]).to.never.be.ok()
expect(captured[c]).to.be.ok() expect(deps[c]).to.be.ok()
expect(result).to.equal(3) expect(result).to.equal(3)
end) end)

View file

@ -67,4 +67,32 @@ return function()
assert(count(store.listeners[source]) == 0, "computed atom did not collect") assert(count(store.listeners[source]) == 0, "computed atom did not collect")
end) end)
it("tracks conditional dependencies", function()
local condition = atom(false)
local a = atom(0)
local b = atom(1)
local reruns = 0
local current
subscribe(function()
return if condition() then a() else b()
end, function(state)
reruns += 1
current = state
end)
condition(true)
expect(reruns).to.equal(1)
expect(current).to.equal(0)
a(1)
b(2)
expect(reruns).to.equal(2)
expect(current).to.equal(1)
condition(false)
expect(reruns).to.equal(3)
expect(current).to.equal(2)
end)
end end

View file

@ -15,8 +15,8 @@ local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
local function atom(...) local function atom(...)
if select("#", ...) == 0 then if select("#", ...) == 0 then
if store.isCapturing() then for set in store.capturing do
store.captured[atom] = true set[atom] = true
end end
return state return state

View file

@ -15,19 +15,18 @@ type Molecule<T> = types.Molecule<T>
@return A new read-only atom. @return A new read-only atom.
]=] ]=]
local function computed<T>(molecule: Molecule<T>, options: AtomOptions<T>?): Molecule<T> local function computed<T>(molecule: Molecule<T>, options: AtomOptions<T>?): Molecule<T>
local captured, state = store.capture(molecule) local dependencies, state = store.capture(molecule)
local computedAtom = atom(state, options) local computedAtom = atom(state, options)
local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" }) local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" })
local function listener() local function listener()
if computedRef.current then if computedRef.current then
computedRef.current(molecule()) dependencies, state = store.reconnect(molecule, dependencies, listener, computedRef.current)
computedRef.current(state)
end end
end end
for atom in captured do store.connect(dependencies, listener, computedAtom)
store.listeners[atom][listener] = computedAtom
end
return computedAtom return computedAtom
end end

View file

@ -6,31 +6,24 @@ type Cleanup = () -> ()
Runs the given callback immediately and whenever any atom it depends on Runs the given callback immediately and whenever any atom it depends on
changes. Returns a cleanup function that unsubscribes the callback. changes. Returns a cleanup function that unsubscribes the callback.
Note that dependencies are only evaluated once when the effect is created,
and conditional dependencies may not work as expected.
@param callback The function to run. @param callback The function to run.
@return A function that unsubscribes the callback. @return A function that unsubscribes the callback.
]=] ]=]
local function effect(callback: () -> any): () -> () local function effect(callback: () -> any): () -> ()
local captured, cleanup = store.capture(callback) local dependencies, cleanup = store.capture(callback)
local function listener() local function listener()
if cleanup then if cleanup then
cleanup() cleanup()
end end
cleanup = callback() dependencies, cleanup = store.reconnect(callback, dependencies, listener)
end end
for atom in captured do store.connect(dependencies, listener)
store.listeners[atom][listener] = true
end
return function() return function()
for atom in captured do store.disconnect(dependencies, listener)
store.listeners[atom][listener] = nil
end
if cleanup then if cleanup then
cleanup() cleanup()

7
src/index.d.ts vendored
View file

@ -82,9 +82,6 @@ declare namespace Charm {
* Runs the given callback immediately and whenever any atom it depends on * Runs the given callback immediately and whenever any atom it depends on
* changes. Returns a cleanup function that unsubscribes the callback. * changes. Returns a cleanup function that unsubscribes the callback.
* *
* Note that dependencies are only evaluated once when the effect is created,
* so conditional dependencies may not work as expected.
*
* @param callback The function to run. * @param callback The function to run.
* @returns A function that unsubscribes the callback. * @returns A function that unsubscribes the callback.
*/ */
@ -123,7 +120,7 @@ declare namespace Charm {
* @param molecule The function to run. * @param molecule The function to run.
* @returns A tuple containing the captured atoms and the result of the function. * @returns A tuple containing the captured atoms and the result of the function.
*/ */
function capture<State>(molecule: Molecule<State>): LuaTuple<[captured: Set<Atom<unknown>>, state: State]>; function capture<State>(molecule: Molecule<State>): LuaTuple<[dependencies: Set<Atom<unknown>>, state: State]>;
/** /**
* Notifies all subscribers of the given atom that the state has changed. * Notifies all subscribers of the given atom that the state has changed.
@ -199,6 +196,7 @@ declare namespace Charm {
* Creates a `ClientSyncer` object that receives patches from the server and * Creates a `ClientSyncer` object that receives patches from the server and
* applies them to the local state. * applies them to the local state.
* *
* @client
* @param options The atoms to synchronize with the server. * @param options The atoms to synchronize with the server.
* @returns A `ClientSyncer` object. * @returns A `ClientSyncer` object.
*/ */
@ -207,6 +205,7 @@ declare namespace Charm {
* Creates a `ServerSyncer` object that sends patches to the client and * Creates a `ServerSyncer` object that sends patches to the client and
* hydrates the client's state. * hydrates the client's state.
* *
* @server
* @param options The atoms to synchronize with the client. * @param options The atoms to synchronize with the client.
* @returns A `ServerSyncer` object. * @returns A `ServerSyncer` object.
*/ */

View file

@ -1,22 +1,71 @@
local types = require(script.Parent.types) local types = require(script.Parent.types)
type Atom<T> = types.Atom<T> type Atom<T> = types.Atom<T>
type Set<T> = types.Set<T>
type WeakMap<K, V> = types.WeakMap<K, V> type WeakMap<K, V> = types.WeakMap<K, V>
local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" }) local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" })
local capturing: Set<Set<Atom<any>>> = {}
local captured: { [Atom<any>]: true } = {} local batched: Set<() -> ()> = {}
local capturing = false
local batched: { [() -> ()]: true } = {}
local batching = false local batching = false
--[=[
Calls the given function and returns the result. If the function yields or
throws an error, the thread is closed and an error is thrown. Regardless of
the outcome, the `finally` function is called to clean up any resources.
@param callback The function to run.
@param finally Cleanup logic to run before error handling.
@param ... Arguments to pass to the callback.
@return The result of the callback.
]=]
local function try<T, U...>(callback: (U...) -> T | any, finally: (() -> ())?, ...: U...): T
if _G.__DEV__ then
local thread = coroutine.create(callback)
local success, result = coroutine.resume(thread, ...)
if finally then
finally()
end
if coroutine.status(thread) == "suspended" then
local source, line, name = debug.info(callback, "sln")
coroutine.close(thread)
error(
"Yielding is not allowed in atom functions. Consider wrapping this code in a Promise or task.defer instead."
.. `\nFunction defined at: {source}:{line}`
.. if name == "" then "" else ` function {name}`
)
elseif not success then
local source, line, name = debug.info(callback, "sln")
error(
"An error occurred while running an atom function"
.. `\nFunction defined at: {source}:{line}`
.. (if name == "" then "" else ` function {name}`)
.. `\nError: {result}`
)
end
return result
end
if not finally then
return callback(...)
end
local success, result = pcall(callback, ...)
finally()
assert(success, result)
return result
end
--[=[ --[=[
Returns whether the given value is an atom. Returns whether the given value is an atom.
@param value The value to check. @param value The value to check.
@return `true` if the value is an atom, otherwise `false`. @return `true` if the value is an atom, otherwise `false`.
]=] ]=]
local function isAtom(value: any) local function isAtom(value: any): boolean
return not not (value and listeners[value]) return not not (value and listeners[value])
end end
@ -34,7 +83,7 @@ local function notify(atom: Atom<any>)
end end
for listener in next, table.clone(listeners[atom]) do for listener in next, table.clone(listeners[atom]) do
listener() try(listener)
end end
end end
@ -45,17 +94,17 @@ end
@param molecule The function to run. @param molecule The function to run.
@return A tuple containing the captured atoms and the result of the function. @return A tuple containing the captured atoms and the result of the function.
]=] ]=]
local function capture<T>(callback: () -> T): ({ [Atom<any>]: true }, T) local function capture<T>(molecule: () -> T): (Set<Atom<any>>, T)
if listeners[callback] then if listeners[molecule] then
return { [callback] = true }, callback() return { [molecule] = true }, molecule()
end end
capturing = true local dependencies: Set<Atom<any>> = {}
local result = callback() capturing[dependencies] = true
capturing = false
local dependencies = table.clone(captured) local result = try(molecule, function()
table.clear(captured) capturing[dependencies] = nil
end)
return dependencies, result return dependencies, result
end end
@ -68,16 +117,17 @@ end
]=] ]=]
local function batch(callback: () -> ()) local function batch(callback: () -> ())
if batching then if batching then
callback() return callback()
return
end end
batching = true batching = true
callback()
batching = false try(callback, function()
batching = false
end)
for listener in batched do for listener in batched do
listener() try(listener)
end end
table.clear(batched) table.clear(batched)
@ -96,44 +146,79 @@ local function peek<T, U...>(callback: ((U...) -> T) | T, ...: U...): T
return callback return callback
end end
if not capturing then if not next(capturing) then
return callback(...) return callback(...)
end end
capturing = false local snapshot = table.clone(capturing)
local result = callback(...)
capturing = true table.clear(capturing)
local result = try(callback, function()
for set in snapshot do
capturing[set] = true
end
end, ...)
return result return result
end end
--[=[ --[=[
Sets whether the store is capturing atoms. If `true`, atoms that are read Subscribes the listener to the changes of the given atoms.
will be tracked and returned by `capture`.
@param dependencies The atoms to listen to.
@param value Whether to capture atoms. @param listener The function to call when the atoms change.
@param ref Optionally bind the lifetime of the listener to a value.
]=] ]=]
local function setCapturing(value: boolean) local function connect(dependencies: Set<Atom<any>>, listener: () -> (), ref: unknown?)
capturing = value for atom in dependencies do
listeners[atom][listener] = ref or true
end
end end
--[=[ --[=[
Returns whether the store is currently capturing atoms. Unsubscribes the listener from the changes of the given atoms.
@return `true` if the store is capturing atoms, otherwise `false`. @param dependencies The atoms to stop listening to.
@param listener The function to stop calling when the atoms change.
]=] ]=]
local function isCapturing() local function disconnect(dependencies: Set<Atom<any>>, listener: () -> ())
return capturing for atom in dependencies do
listeners[atom][listener] = nil
end
end
--[=[
Disconnects the listener and evaluates the molecule again to capture any
new dependencies. Useful for tracking conditional dependencies.
@param molecule The atom or molecule to evaluate.
@param dependencies The current dependencies.
@param listener The listener to disconnect.
@param ref Optionally bind the lifetime of the listener to a value.
@return The new dependencies and the state of the molecule.
]=]
local function reconnect<T>(
molecule: Atom<T>,
dependencies: Set<Atom<any>>,
listener: () -> (),
ref: unknown?
): (Set<Atom<any>>, T)
disconnect(dependencies, listener)
local nextDependencies, state = capture(molecule)
connect(nextDependencies, listener, ref)
return nextDependencies, state
end end
return { return {
listeners = listeners, listeners = listeners,
captured = captured, capturing = capturing,
isAtom = isAtom, isAtom = isAtom,
notify = notify, notify = notify,
capture = capture, capture = capture,
batch = batch, batch = batch,
peek = peek, peek = peek,
setCapturing = setCapturing, connect = connect,
isCapturing = isCapturing, disconnect = disconnect,
reconnect = reconnect,
} }

View file

@ -16,20 +16,18 @@ local function subscribe<T>(molecule: Molecule<T>, callback: (state: T, prev: T)
local function listener() local function listener()
local prevState = state local prevState = state
state = molecule()
captured, state = store.reconnect(molecule, captured, listener)
if state ~= prevState then if state ~= prevState then
callback(state, prevState) callback(state, prevState)
end end
end end
for atom in captured do store.connect(captured, listener)
store.listeners[atom][listener] = true
end
return function() return function()
for atom in captured do store.disconnect(captured, listener)
store.listeners[atom][listener] = nil
end
end end
end end

View file

@ -1,3 +1,5 @@
export type Set<T> = { [T]: true }
export type WeakMap<K, V> = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" })) export type WeakMap<K, V> = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" }))
--[=[ --[=[