refactor: improve logic

This commit is contained in:
littensy 2025-05-12 02:15:49 -07:00
parent 210e948225
commit 72f936591e
48 changed files with 434 additions and 349 deletions

View file

@ -2,20 +2,22 @@ local ServerScriptService = game:GetService("ServerScriptService")
local suite = require(ServerScriptService.Tests.suite)
local describe = suite.describe
local function spec(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
suite.describe("charm-sync", function()
suite.describe("client", function()
require("./client.spec")
end)
suite.describe("server", function()
require("./server.spec")
end)
suite.describe("flatten", function()
require("./flatten.spec")
end)
suite.describe("patch", function()
require("./patch.spec")
end)
suite.describe("validate", function()
require("./validate.spec")
end)
end
describe("charm-sync", function()
spec("client")
spec("server")
spec("flatten")
spec("patch")
spec("validate")
end)
return {}

View file

@ -2,51 +2,46 @@ local signals = require("./signals")
local types = require("./types")
type Atom<T> = types.Atom<T>
type Getter<T> = types.Getter<T>
type Setter<T> = types.Setter<T>
type Update<T> = types.Update<T>
local signal = signals.signal
local function atomGetterSetterEquals<T>(
source: () -> T,
setSource: (((T) -> T) | T) -> T,
equals: (T, T) -> boolean
): Atom<T>
return function(...: ((T) -> T) | T): T
type Equals<T> = (current: T, incoming: T) -> boolean
local function createAtomWithEqualityCheck<T>(getter: Getter<T>, setter: Setter<T>, equals: Equals<T>): Atom<T>
local function atom(...: Update<T>): T
if select("#", ...) == 0 then
return source()
return getter()
end
local nextState = (...)
if type(nextState) == "function" then
return setSource(function(currentState): T
local nextState = nextState(currentState)
return if equals(currentState, nextState) then currentState else nextState
end)
else
return setSource(function(currentState): T
return if equals(currentState, nextState) then currentState else nextState
end)
end
return setter(function(currentState): T
local nextState = if type(nextState) == "function" then nextState(currentState) else nextState
return if equals(currentState, nextState) then currentState else nextState
end)
end
return atom
end
local function atomGetterSetter<T>(source: () -> T, setSource: (((T) -> T) | T) -> T): Atom<T>
return function(...: ((T) -> T) | T): T
if select("#", ...) == 0 then
return source()
else
return setSource((...))
end
local function createAtom<T>(getter: Getter<T>, setter: Setter<T>): Atom<T>
local function atom(...: Update<T>): T
return if select("#", ...) == 0 then getter() else setter((...))
end
return atom
end
local function atom<T>(state: T, equals: ((previous: T, current: T) -> boolean)?): Atom<T>
local source, setSource = signal(state)
local function atom<T>(state: T, equals: Equals<T>?): Atom<T>
local getter, setter = signal(state)
if equals then
return atomGetterSetterEquals(source, setSource, equals)
if equals == nil then
return createAtom(getter, setter)
else
return atomGetterSetter(source, setSource)
return createAtomWithEqualityCheck(getter, setter, equals)
end
end

View file

@ -2,13 +2,11 @@ local signals = require("./signals")
local onEffectCleanup = signals.onEffectCleanup
type Cleanup = () -> ()
local function effect(callback: (dispose: () -> ()) -> ...Cleanup?): () -> ()
local disposeImmediately = false
local function effect(callback: (dispose: () -> ()) -> ...(() -> ())?): () -> ()
local disposed = false
local function dispose()
disposeImmediately = true
disposed = true
end
dispose = signals.effect(function()
@ -18,7 +16,7 @@ local function effect(callback: (dispose: () -> ()) -> ...Cleanup?): () -> ()
end
end)
if disposeImmediately then
if disposed then
dispose()
end

View file

@ -2,9 +2,7 @@ local signals = require("./signals")
local onScopeDispose = signals.onScopeDispose
type Cleanup = () -> ()
local function effectScope(callback: () -> ...Cleanup?, detached: boolean?): () -> ()
local function effectScope(callback: () -> ...(() -> ())?, detached: boolean?): () -> ()
return signals.effectScope(function()
local cleanup = callback()
if cleanup then

View file

@ -1,6 +1,9 @@
local lib = require(script.lib)
export type Atom<T = any> = lib.Atom<T>
export type Getter<T = any> = lib.Getter<T>
export type Selector<T = any> = lib.Selector<T>
export type Setter<T = any> = lib.Setter<T>
export type Update<T = any> = lib.Update<T>
return lib

View file

@ -10,7 +10,10 @@ local types = require("./types")
local untracked = require("./untracked")
export type Atom<T = any> = types.Atom<T>
export type Getter<T = any> = types.Getter<T>
export type Selector<T = any> = types.Selector<T>
export type Setter<T = any> = types.Setter<T>
export type Update<T = any> = types.Update<T>
local computed = signals.computed
local effectScope = signals.effectScope

View file

@ -1,32 +1,27 @@
local signals = require("./signals")
local computed = signals.computed
local effect = signals.effect
local setCurrentSub = signals.setCurrentSub
local function listen<T>(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> ()
local disposeImmediately = false
local tracked = computed(function()
return selector()
end)
local current: T
local disposed = false
local function dispose()
disposeImmediately = true
disposed = true
end
dispose = effect(function()
local previous: T? = current
current = selector()
if current ~= previous then
local currentSub = setCurrentSub(nil)
local success, result: unknown = pcall(listener, current, previous, dispose)
setCurrentSub(currentSub)
if not success then
error(`listen() encountered an error: {result}`)
end
end
local previous = current
current = tracked()
listener(current, previous, dispose)
end)
if disposeImmediately then
if disposed then
dispose()
end

View file

@ -1,3 +1,4 @@
local noYield = require("./noYield")
local signals = require("./signals")
local computed = signals.computed
@ -17,14 +18,18 @@ local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, mapper: (ite
local function map(value: VI, key: KI): (VO, KO)
local currentSub = setCurrentSub(nil)
local success, valueOut, keyOut = pcall(mapper, value, key)
local success, valueOut, keyOut = noYield(mapper, value, key)
setCurrentSub(currentSub)
if not success then
error(`mapped() transform function failed: {valueOut}`)
end
return valueOut, if keyOut ~= nil then keyOut else key :: any
if keyOut == nil then
keyOut = key :: any
end
return valueOut, keyOut
end
return computed(function(previousOutput)
@ -53,9 +58,11 @@ local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, mapper: (ite
local valueOut, keyOut = map(value, key)
currentOutput = ensureCanMutateOutput(currentOutput, previousOutput)
currentOutput[keyOut] = valueOut
keyCache[key] = keyOut
if currentOutput[keyOut] ~= valueOut then
currentOutput = ensureCanMutateOutput(currentOutput, previousOutput)
currentOutput[keyOut] = valueOut
keyCache[key] = keyOut
end
end
return currentOutput

View file

@ -0,0 +1,25 @@
local function handleNoYield(thread: thread, ok: boolean, ...)
if not ok then
local err = (...)
if typeof(err) == "string" then
return false, debug.traceback(thread, err)
else
return false, tostring(err)
end
end
if coroutine.status(thread) ~= "dead" then
return false, debug.traceback(thread, "Attempted to yield!")
end
return true, ...
end
local function noYield<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...)
local thread = coroutine.create(callback)
return handleNoYield(thread, coroutine.resume(thread, ...))
end
-- To eliminate the overhead of noYield returning callback(...) in debug mode,
-- replace it with pcall to avoid wrapping the callback in another function.
return if _G.__DEV__ then noYield else pcall :: never

View file

@ -2,23 +2,19 @@ local signals = require("./signals")
local effect = signals.effect
local effectScope = signals.effectScope
local getCurrentSub = signals.getCurrentSub
local setCurrentSub = signals.setCurrentSub
local onEffectCleanup = signals.onEffectCleanup
local onScopeDispose = signals.onScopeDispose
local getCurrentSub = signals.getCurrentSub
type Cleanup = () -> ()
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...Cleanup?): () -> ()
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> ()
local scopes: { [K]: () -> () } = {}
local currentItems: { [K]: V } = {}
local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V })
for key in previousItems do
if currentItems[key] == nil then
local dispose = scopes[key]
if currentItems[key] == nil and scopes[key] then
scopes[key]()
scopes[key] = nil
dispose()
end
end
@ -46,13 +42,7 @@ local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key
currentItems = selector()
if currentItems ~= previousItems then
local currentSub = setCurrentSub(nil)
local success, result: unknown = pcall(updateScopes, currentItems, previousItems)
setCurrentSub(currentSub)
if not success then
error(`observe() encountered an error: {result}`, 2)
end
updateScopes(currentItems, previousItems)
end
end)

View file

@ -1,4 +1,4 @@
--[=[
--[[
alien-signals 2.0.1 implementation adopted from
https://github.com/Nicell/alien-signals-luau
https://github.com/stackblitz/alien-signals
@ -24,8 +24,9 @@
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]=]
]]
local noYield = require("./noYield")
local system = require("./system")
export type ReactiveFlags = system.ReactiveFlags
@ -43,12 +44,12 @@ local PENDING = 0b0100000
local QUEUED = 0b1000000
type EffectScope = ReactiveNode & {
cleanup: (() -> ())?,
cleanups: { () -> () },
}
type Effect = ReactiveNode & {
fn: () -> (),
cleanup: (() -> ())?,
cleanups: { () -> () },
}
type Computed<T = any> = ReactiveNode & {
@ -99,42 +100,28 @@ local function setCurrentScope(scope: Effect | EffectScope?)
return prevScope
end
local function addCleanup(e: Effect | EffectScope, cleanup: () -> ())
local prevCleanup: (() -> ())? = e.cleanup
local nextCleanup = cleanup
if prevCleanup then
function cleanup()
prevCleanup()
nextCleanup()
end
end
(e :: Effect).cleanup = cleanup
end
local function onEffectCleanup(cleanup: () -> ())
if activeSub then
addCleanup(activeSub :: Effect, cleanup)
table.insert((activeSub :: Effect).cleanups, cleanup)
end
end
local function onScopeDispose(cleanup: () -> ())
if activeScope then
addCleanup(activeScope, cleanup)
table.insert(activeScope.cleanups, cleanup)
end
end
local function cleanupEffect(e: Effect | EffectScope)
if e.cleanup then
local prev = setCurrentSub(nil)
local success, result: unknown = pcall(e.cleanup)
setCurrentSub(prev);
(e :: Effect).cleanup = nil
for _, cleanup in e.cleanups :: { () -> () } do
local prevSub = setCurrentSub(nil)
local success, result: unknown = noYield(cleanup)
setCurrentSub(prevSub)
if not success then
error(result)
end
end
table.clear(e.cleanups)
end
local function run(e: Effect | EffectScope, flags: ReactiveFlags): ()
@ -142,7 +129,7 @@ local function run(e: Effect | EffectScope, flags: ReactiveFlags): ()
cleanupEffect(e)
local prev = setCurrentSub(e)
startTracking(e)
local success, result: unknown = pcall((e :: Effect).fn)
local success, result: unknown = noYield((e :: Effect).fn)
setCurrentSub(prev)
endTracking(e)
if not success then
@ -208,7 +195,7 @@ local function updateComputed(c: Computed): boolean
local prevSub = setCurrentSub(c)
startTracking(c)
local oldValue = c.value
local success, result = pcall(c.getter, oldValue)
local success, result = noYield(c.getter, oldValue)
setCurrentSub(prevSub)
endTracking(c)
if not success then
@ -254,7 +241,7 @@ local function signalGetterSetter<T>(this: Signal<T>): (() -> T, (value: ((T) ->
local newValue = newValue :: T
if type(newValue) == "function" then
local prev = setCurrentSub(nil)
local success, result = pcall(newValue, value)
local success, result = noYield(newValue, value)
setCurrentSub(prev)
if not success then
error(result)
@ -336,6 +323,7 @@ end
local function effect(fn: () -> ()): () -> ()
local e: Effect = {
fn = fn,
cleanups = {},
flags = WATCHING,
}
if activeSub then
@ -344,7 +332,7 @@ local function effect(fn: () -> ()): () -> ()
link(e, activeScope)
end
local prev = setCurrentSub(e)
local success, result: unknown = pcall(fn)
local success, result: unknown = noYield(fn)
setCurrentSub(prev)
if not success then
error(result)
@ -354,6 +342,7 @@ end
local function effectScope(fn: () -> (), detached: boolean?): () -> ()
local e: EffectScope = {
cleanups = {},
flags = NONE,
}
if activeScope and not detached then
@ -361,7 +350,7 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> ()
end
local prevSub = setCurrentSub(nil)
local prevScope = setCurrentScope(e)
local success, result: unknown = pcall(fn)
local success, result: unknown = noYield(fn)
setCurrentScope(prevScope)
setCurrentSub(prevSub)
if not success then
@ -401,7 +390,7 @@ local function unwatched(node: Signal | Computed | Effect | EffectScope)
toRemove = unlink(toRemove, node)
until not toRemove
end
elseif (node :: Signal).previousValue == nil then
elseif (node :: Effect).cleanups ~= nil then
effectStop(node :: Effect)()
end
end

View file

@ -1,26 +1,21 @@
local signals = require("./signals")
local computed = signals.computed
local effect = signals.effect
local setCurrentSub = signals.setCurrentSub
local function subscribe<T>(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> ()
local current: T
local mounted = true
local tracked = computed(function()
return selector()
end)
local current = tracked()
return effect(function()
local previous = current
current = selector()
current = tracked()
if mounted then
mounted = false
elseif current ~= previous then
local currentSub = setCurrentSub(nil)
local success, result: unknown = pcall(listener, current, previous)
setCurrentSub(currentSub)
if not success then
error(`subscribe() encountered an error: {result}`)
end
if current ~= previous then
listener(current, previous)
end
end)
end

View file

@ -1,4 +1,4 @@
--[=[
--[[
alien-signals 2.0.1 implementation adopted from
https://github.com/Nicell/alien-signals-luau
https://github.com/stackblitz/alien-signals
@ -24,7 +24,7 @@
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]=]
]]
export type ReactiveNode = {
deps: Link?,

View file

@ -1,5 +1,9 @@
export type Getter<T = any> = () -> T
export type Setter<T = any> = (update: Update<T>) -> T
export type Update<T = any> = T | (previous: T) -> T
export type Selector<T = any> = () -> T
export type Atom<T = any> = ((((T) -> T) | T) -> T) & (() -> T)
export type Atom<T = any> = Setter<T> & Getter<T>
return {}

View file

@ -1,10 +1,11 @@
local noYield = require("./noYield")
local signals = require("./signals")
local setCurrentSub = signals.setCurrentSub
local function untracked<T, Args...>(callback: ((Args...) -> T) | (Args...) -> (), ...: Args...): T
local currentSub = setCurrentSub(nil)
local success, result = pcall(callback, ...)
local success, result = noYield(callback, ...)
setCurrentSub(currentSub)
if not success then

View file

@ -1,72 +0,0 @@
local Charm = require("../src/lib")
local suite = require("@tests/suite")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local effectScope = Charm.effectScope
local test = suite.test
--- Creates a weak reference to a value.
--- @return Function to dereference the value.
local function weakRef<T>(value: T): () -> T?
local ref = setmetatable({ value = value }, { __mode = "v" })
return function()
return ref.value
end
end
--- Triggers garbage collection by allocating excessive memory.
local function gc()
for _ = 1, 1000 do
local _ = table.create(100, 0)
end
end
--- In alien-signals, a computed value unlinks from dependencies when its last
--- parent scope is disposed. Calling a computed value outside of a scope links
--- it to dependencies, but the dependencies hold strong references to the
--- computed value, so it won't GC until called in a scope and disposed.
---
--- This function releases a computed value by linking it to a scope and then
--- immediately closing it, causing alien-signals to unlink the computed value
--- and allow it to be garbage collected.
local function release(getter: () -> ())
effectScope(function()
getter()
end)()
end
test("should release computed cache", function()
local srcRef = weakRef({})
local src = atom(srcRef())
local c = computed(function()
return src()
end)
c() -- cache src value
src(nil) -- release value
c = release(c) -- release computed
gc()
assert(srcRef() == nil, "should release computed cache")
end)
test("should not release effect for atom", function()
local effectCalls = 0
local src = atom(0)
effect(function()
effectCalls += 1
src()
end)
assert(effectCalls == 1, "should call effect once")
gc()
src(src() + 1)
assert(effectCalls == 2, "should call effect again")
end)
return {}

View file

@ -1,5 +1,5 @@
local Charm = require("../src/lib")
local suite = require("@tests/suite")
local suite = require("@test/suite")
local atom = Charm.atom
local listen = Charm.listen
@ -26,4 +26,34 @@ test("should listen to selector", function()
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should track inner subs", function()
local order = {}
local a = atom(1)
local unsub = listen(a, function()
table.insert(order, 1)
listen(a, function()
table.insert(order, 2)
end)
listen(a, function()
table.insert(order, 3)
end)
end)
assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`)
table.clear(order)
a(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
a(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
unsub()
a(a() + 1)
assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
end)
return {}

View file

@ -1,5 +1,5 @@
local Charm = require("../src/lib")
local suite = require("@tests/suite")
local suite = require("@test/suite")
local atom = Charm.atom
local mapped = Charm.mapped

View file

@ -1,5 +1,5 @@
local Charm = require("../src/lib")
local suite = require("@tests/suite")
local suite = require("@test/suite")
local atom = Charm.atom
local computed = Charm.computed

View file

@ -1,5 +1,5 @@
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local suite = require("@test/suite")
local computed = signals.computed
local signal = signals.signal

View file

@ -1,5 +1,5 @@
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local suite = require("@test/suite")
local computed = signals.computed
local effect = signals.effect

View file

@ -1,5 +1,5 @@
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local suite = require("@test/suite")
local effect = signals.effect
local effectScope = signals.effectScope

View file

@ -1,10 +1,10 @@
local Charm = require("@pkgs/charm/src/lib")
local suite = require("@tests/suite")
local signals = require("../../src/signals")
local suite = require("@test/suite")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local effectScope = Charm.effectScope
local signal = signals.signal
local computed = signals.computed
local effect = signals.effect
local effectScope = signals.effectScope
local test = suite.test
--- Creates a weak reference to a value.
@ -39,23 +39,23 @@ end
test("should release computed cache", function()
local srcRef = weakRef({})
local src = atom(srcRef())
local src, setSrc = signal(srcRef())
local c = computed(function()
return src()
end)
c() -- cache src value
src(nil) -- release value
setSrc(nil) -- release value
c = release(c) -- release computed
gc()
assert(srcRef() == nil, "should release computed cache")
end)
test("should not release effect for atom", function()
test("should not release effect for signal", function()
local effectCalls = 0
local src = atom(0)
local src, setSrc = signal(0)
effect(function()
effectCalls += 1
@ -65,7 +65,7 @@ test("should not release effect for atom", function()
assert(effectCalls == 1, "should call effect once")
gc()
src(src() + 1)
setSrc(src() + 1)
assert(effectCalls == 2, "should call effect again")
end)

View file

@ -1,5 +1,5 @@
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local suite = require("@test/suite")
local computed = signals.computed
local effect = signals.effect

View file

@ -1,21 +1,27 @@
local suite = require("@tests/suite")
local suite = require("@test/suite")
local describe = suite.describe
local function spec(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
suite.describe("signals", function()
suite.describe("computed", function()
require("./computed.spec")
end)
suite.describe("effect", function()
require("./effect.spec")
end)
suite.describe("effectScope", function()
require("./effectScope.spec")
end)
suite.describe("issue_48", function()
require("./issue_48.spec")
end)
suite.describe("gc", function()
require("./gc.spec")
end)
suite.describe("topology", function()
require("./topology.spec")
end)
suite.describe("untrack", function()
require("./untrack.spec")
end)
end
describe("signals", function()
spec("computed")
spec("effect")
spec("effectScope")
spec("issue_48")
spec("gc")
spec("topology")
spec("untrack")
end)
return {}

View file

@ -1,5 +1,5 @@
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local suite = require("@test/suite")
local computed = signals.computed
local effect = signals.effect

View file

@ -1,5 +1,5 @@
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local suite = require("@test/suite")
local computed = signals.computed
local setCurrentSub = signals.setCurrentSub

View file

@ -1,19 +1,20 @@
local suite = require("@tests/suite")
local describe = suite.describe
local function spec(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
local suite = require("@test/suite")
require("./signals/spec")
spec("gc")
spec("listen")
spec("mapped")
spec("observe")
spec("subscribe")
suite.describe("charm", function()
suite.describe("listen", function()
require("./listen.spec")
end)
suite.describe("mapped", function()
require("./mapped.spec")
end)
suite.describe("observe", function()
require("./observe.spec")
end)
suite.describe("subscribe", function()
require("./subscribe.spec")
end)
end)
return {}

View file

@ -1,11 +1,12 @@
local Charm = require("../src/lib")
local suite = require("@tests/suite")
local suite = require("@test/suite")
type Atom<T> = Charm.Atom<T>
local atom = Charm.atom
local computed = Charm.computed
local subscribe = Charm.subscribe
local listen = Charm.listen
local test = suite.test
test("should subscribe to selector", function()
@ -55,4 +56,33 @@ test("should ensure correct execution order", function()
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
end)
test("should track inner subs", function()
local order = {}
local a = atom(1)
local unsub = subscribe(a, function()
table.insert(order, 1)
listen(a, function()
table.insert(order, 2)
end)
listen(a, function()
table.insert(order, 3)
end)
end)
assert(table.concat(order) == "", "should not run immediately")
a(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
a(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
unsub()
a(a() + 1)
assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
end)
return {}

View file

@ -1,32 +1,56 @@
local Charm = require(script.Parent.Charm)
local React = require(script.Parent.React)
local listen = Charm.listen
local effect = Charm.effect
local useBinding = React.useBinding
local useEffect = React.useEffect
local useState = React.useState
--[=[
A hook that subscribes to changes in the given atom or selector. The
component is re-rendered whenever the state changes.
If the `dependencies` array is provided, the subscription to the atom or
selector is re-created whenever the dependencies change. Otherwise, the
subscription is created once when the component is mounted.
@param callback The atom or selector to subscribe to.
@param dependencies An array of values that the subscription depends on.
@return The current state.
]=]
local function useAtom<State>(callback: () -> State, dependencies: { any }?): State
local state, setState = useState(callback)
--- A hook that subscribes to changes in the given atom or selector. The
--- component is re-rendered whenever the state changes.
---
--- If the `dependencies` array is provided, the effect is re-created whenever
--- the dependencies change. Otherwise, the subscription is created once when
--- the component is mounted.
---
--- @param selector The atom or selector to subscribe to.
--- @param dependencies An array of values that the subscription depends on.
--- @return The current state.
local function useAtom<State>(selector: () -> State, dependencies: { any }?): State
local state, setState = useState(selector)
useEffect(function()
return listen(callback, setState)
return effect(function()
setState(selector())
end)
end, dependencies or {})
return state
end
--- A hook that subscribes to changes in the given atom or selector. Returns
--- a binding that updates when the state changes.
---
--- If the `dependencies` array is provided, the effect is re-created whenever
--- the dependencies change. Otherwise, the subscription is created once when
--- the component is mounted.
---
--- @param selector The atom or selector to subscribe to.
--- @param dependencies An array of values that the subscription depends on.
--- @return A binding that stores the current state.
local function useAtomBinding<State>(selector: () -> State, dependencies: { any }?)
local binding, setBinding = useBinding(selector)
useEffect(function()
return effect(function()
setBinding(selector())
end)
end, dependencies or {})
return binding
end
return {
useAtom = useAtom,
useAtomBinding = useAtomBinding,
}

View file

@ -2,16 +2,13 @@ local ServerScriptService = game:GetService("ServerScriptService")
local suite = require(ServerScriptService.Tests.suite)
local describe = suite.describe
local function spec(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
suite.describe("react-charm", function()
suite.describe("useAtom", function()
require("./useAtom.spec")
end)
suite.describe("useAtomBinding", function()
require("./useAtomBinding.spec")
end)
end
describe("react-charm", function()
spec("useAtom")
end)
return {}

View file

@ -0,0 +1,50 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm)
local renderHook = require(script.Parent.helpers.renderHook)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local useAtomBinding = ReactCharm.useAtomBinding
local test = suite.test
test("tracks atom state", function()
local state = atom(0)
local hook = renderHook(function()
return useAtomBinding(state)
end)
assert(hook.result:getValue() == 0, "should return 0")
state(1)
assert(hook.result:getValue() == 1, "should return 1")
end)
test("accepts a selector", function()
local state = atom(0)
local hook = renderHook(function()
return useAtomBinding(function()
return 2 * state()
end)
end)
assert(hook.result:getValue() == 0, "should return 0")
state(1)
assert(hook.result:getValue() == 2, "should return 2")
end)
test("tracks dependencies", function()
local state = atom(0)
local hook = renderHook(function()
return useAtomBinding(state, { state })
end)
assert(hook.result:getValue() == 0, "should return 0")
state(1)
assert(hook.result:getValue() == 1, "should return 1")
state = atom(0)
hook.rerender()
assert(hook.result:getValue() == 0, "should return 0")
state(1)
assert(hook.result:getValue() == 1, "should return 1")
end)
return {}

View file

@ -5,15 +5,14 @@ local subscribe = Charm.subscribe
local source = Vide.source
local cleanup = Vide.cleanup
--[=[
Subscribes to the state of an atom and returns a Vide source.
@param callback The atom or selector to subscribe to.
@return The reactive source.
]=]
local function useAtom<State>(callback: () -> State): () -> State
local state = source(callback())
cleanup(subscribe(callback, state))
--- Subscribes to the state of an atom or selector. Returns a source that
--- updates when the state changes.
---
--- @param selector The atom or selector to subscribe to.
--- @return The reactive source.
local function useAtom<State>(selector: () -> State): () -> State
local state = source(selector())
cleanup(subscribe(selector, state))
return state
end

View file

@ -2,16 +2,10 @@ local ServerScriptService = game:GetService("ServerScriptService")
local suite = require(ServerScriptService.Tests.suite)
local describe = suite.describe
local function spec(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
suite.describe("vide-charm", function()
suite.describe("useAtom", function()
require("./useAtom.spec")
end)
end
describe("vide-charm", function()
spec("useAtom")
end)
return {}