mirror of
https://github.com/littensy/charm.git
synced 2026-10-23 05:46:27 +00:00
feat: allow conditional dependencies
This commit is contained in:
parent
fbc86d22c6
commit
aa52c98cd9
12 changed files with 235 additions and 84 deletions
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,30 @@ 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)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
if store.capturing.current then
|
||||||
store.captured[atom] = true
|
store.capturing.current[atom] = true
|
||||||
end
|
end
|
||||||
|
|
||||||
return state
|
return state
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -13,24 +13,20 @@ type Cleanup = () -> ()
|
||||||
@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()
|
||||||
|
|
|
||||||
2
src/index.d.ts
vendored
2
src/index.d.ts
vendored
|
|
@ -123,7 +123,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.
|
||||||
|
|
|
||||||
138
src/store.luau
138
src/store.luau
|
|
@ -4,19 +4,55 @@ 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 captured: { [Atom<any>]: true } = {}
|
local capturing: { current: { [Atom<any>]: true }? } = {}
|
||||||
local capturing = false
|
|
||||||
|
|
||||||
local batched: { [() -> ()]: true } = {}
|
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
|
||||||
|
coroutine.close(thread)
|
||||||
|
error("Yielding is not allowed in atom functions")
|
||||||
|
elseif not success then
|
||||||
|
error("An error occurred while running an atom function")
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = callback(...)
|
||||||
|
|
||||||
|
if finally then
|
||||||
|
finally()
|
||||||
|
end
|
||||||
|
|
||||||
|
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 +70,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 +81,19 @@ 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): ({ [Atom<any>]: true }, T)
|
||||||
if listeners[callback] then
|
if listeners[molecule] then
|
||||||
return { [callback] = true }, callback()
|
return { [molecule] = true }, molecule()
|
||||||
end
|
end
|
||||||
|
|
||||||
capturing = true
|
local dependencies = {}
|
||||||
local result = callback()
|
local previous = capturing.current
|
||||||
capturing = false
|
|
||||||
|
|
||||||
local dependencies = table.clone(captured)
|
capturing.current = dependencies
|
||||||
table.clear(captured)
|
|
||||||
|
local result = try(molecule, function()
|
||||||
|
capturing.current = previous
|
||||||
|
end)
|
||||||
|
|
||||||
return dependencies, result
|
return dependencies, result
|
||||||
end
|
end
|
||||||
|
|
@ -68,16 +106,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()
|
|
||||||
|
try(callback, function()
|
||||||
batching = false
|
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 +135,77 @@ local function peek<T, U...>(callback: ((U...) -> T) | T, ...: U...): T
|
||||||
return callback
|
return callback
|
||||||
end
|
end
|
||||||
|
|
||||||
if not capturing then
|
if not capturing.current then
|
||||||
return callback(...)
|
return callback(...)
|
||||||
end
|
end
|
||||||
|
|
||||||
capturing = false
|
local previous = capturing.current
|
||||||
local result = callback(...)
|
|
||||||
capturing = true
|
capturing.current = nil
|
||||||
|
|
||||||
|
local result = try(callback, function()
|
||||||
|
capturing.current = previous
|
||||||
|
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 value Whether to capture atoms.
|
@param dependencies The atoms to listen to.
|
||||||
|
@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: { [Atom<any>]: true }, 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: { [Atom<any>]: true }, 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: { [Atom<any>]: true },
|
||||||
|
listener: () -> (),
|
||||||
|
ref: unknown?
|
||||||
|
): ({ [Atom<any>]: true }, 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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue