mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
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:
parent
fbc86d22c6
commit
a90d131fed
14 changed files with 290 additions and 98 deletions
25
README.md
25
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,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
|
||||||
|
|
|
||||||
|
|
@ -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
|
for set in store.capturing do
|
||||||
store.captured[atom] = true
|
set[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
|
||||||
|
|
|
||||||
|
|
@ -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
7
src/index.d.ts
vendored
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
161
src/store.luau
161
src/store.luau
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" }))
|
||||||
|
|
||||||
--[=[
|
--[=[
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue