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

@ -16,7 +16,7 @@ jobs:
run: sh bin/check.sh run: sh bin/check.sh
- name: Run tests - name: Run tests
run: lute tests/init.luau run: lute test/init.luau
- name: Build models - name: Build models
run: sh bin/build.sh run: sh bin/build.sh

View file

@ -4,7 +4,7 @@
"LocalShadow": false "LocalShadow": false
}, },
"aliases": { "aliases": {
"tests": "tests", "test": "test",
"pkgs": "pkgs" "pkgs": "pkgs"
} }
} }

View file

@ -1,6 +1,6 @@
<p align="center"> <p align="center">
<p align="center"> <p align="center">
<img width="150" height="150" src="https://raw.githubusercontent.com/littensy/charm/main/images/logo.png" alt="Logo"> <img width="150" height="150" src="https://raw.githubusercontent.com/littensy/charm/main/assets/logo.png" alt="Logo">
</p> </p>
<h1 align="center"><b>Charm</b></h1> <h1 align="center"><b>Charm</b></h1>
<p align="center"> <p align="center">

View file

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Before After
Before After

42
benches/propagate.luau Normal file
View file

@ -0,0 +1,42 @@
local Charm = require("@pkgs/charm/src/lib")
local bench = require("./bench")
local signals = Charm.signals
for _, w in { 10, 100 } do
for _, h in { 10, 100 } do
bench.run(`charm {w}*{h}`, function()
local src = Charm.atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = Charm.computed(function()
return prev() + 1
end)
end
Charm.effect(function()
last()
end)
end
src(src() + 1)
end)
bench.run(`alien-signals {w}*{h}`, function()
local src, setSrc = signals.signal(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = signals.computed(function()
return prev() + 1
end)
end
signals.effect(function()
last()
end)
end
setSrc(src() + 1)
end)
end
end

View file

@ -1,27 +0,0 @@
local Charm = require("@pkgs/charm/src/lib")
local bench = require("./bench")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
for _, w in { 1, 10, 100 } do
for _, h in { 1, 10, 100 } do
bench.run(`propagate {w}*{h}`, function()
local src = atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
effect(function()
last()
end)
end
src(src() + 1)
end)
end
end

View file

@ -7,10 +7,10 @@ luau-lsp analyze \
--flag:LuauFixIndexerSubtypingOrdering=true \ --flag:LuauFixIndexerSubtypingOrdering=true \
--sourcemap=sourcemap.json \ --sourcemap=sourcemap.json \
--ignore="**/_Index/**" \ --ignore="**/_Index/**" \
pkgs tests pkgs test
selene pkgs tests selene pkgs test
stylua --check pkgs tests stylua --check pkgs test
rm bin/roblox.d.luau rm bin/roblox.d.luau

View file

@ -15,14 +15,14 @@
"ServerScriptService": { "ServerScriptService": {
"$className": "ServerScriptService", "$className": "ServerScriptService",
"Tests": { "Tests": {
"$path": "tests", "$path": "test",
"Charm": { "$path": "pkgs/charm/tests" }, "Charm": { "$path": "pkgs/charm/tests" },
"CharmSync": { "$path": "pkgs/charm-sync/tests" }, "CharmSync": { "$path": "pkgs/charm-sync/tests" },
"ReactCharm": { "$path": "pkgs/react-charm/tests" }, "ReactCharm": { "$path": "pkgs/react-charm/tests" },
"VideCharm": { "$path": "pkgs/vide-charm/tests" } "VideCharm": { "$path": "pkgs/vide-charm/tests" }
}, },
"Benchmarks": { "Benchmarks": {
"$path": "benchmarks" "$path": "benches"
} }
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,10 @@ local types = require("./types")
local untracked = require("./untracked") local untracked = require("./untracked")
export type Atom<T = any> = types.Atom<T> 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 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 computed = signals.computed
local effectScope = signals.effectScope local effectScope = signals.effectScope

View file

@ -1,32 +1,27 @@
local signals = require("./signals") local signals = require("./signals")
local computed = signals.computed
local effect = signals.effect local effect = signals.effect
local setCurrentSub = signals.setCurrentSub
local function listen<T>(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () 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 current: T
local disposed = false
local function dispose() local function dispose()
disposeImmediately = true disposed = true
end end
dispose = effect(function() dispose = effect(function()
local previous: T? = current local previous = current
current = selector() current = tracked()
listener(current, previous, dispose)
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
end) end)
if disposeImmediately then if disposed then
dispose() dispose()
end end

View file

@ -1,3 +1,4 @@
local noYield = require("./noYield")
local signals = require("./signals") local signals = require("./signals")
local computed = signals.computed 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 function map(value: VI, key: KI): (VO, KO)
local currentSub = setCurrentSub(nil) local currentSub = setCurrentSub(nil)
local success, valueOut, keyOut = pcall(mapper, value, key) local success, valueOut, keyOut = noYield(mapper, value, key)
setCurrentSub(currentSub) setCurrentSub(currentSub)
if not success then if not success then
error(`mapped() transform function failed: {valueOut}`) error(`mapped() transform function failed: {valueOut}`)
end end
return valueOut, if keyOut ~= nil then keyOut else key :: any if keyOut == nil then
keyOut = key :: any
end
return valueOut, keyOut
end end
return computed(function(previousOutput) 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) local valueOut, keyOut = map(value, key)
currentOutput = ensureCanMutateOutput(currentOutput, previousOutput) if currentOutput[keyOut] ~= valueOut then
currentOutput[keyOut] = valueOut currentOutput = ensureCanMutateOutput(currentOutput, previousOutput)
keyCache[key] = keyOut currentOutput[keyOut] = valueOut
keyCache[key] = keyOut
end
end end
return currentOutput 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 effect = signals.effect
local effectScope = signals.effectScope local effectScope = signals.effectScope
local getCurrentSub = signals.getCurrentSub
local setCurrentSub = signals.setCurrentSub
local onEffectCleanup = signals.onEffectCleanup local onEffectCleanup = signals.onEffectCleanup
local onScopeDispose = signals.onScopeDispose local onScopeDispose = signals.onScopeDispose
local getCurrentSub = signals.getCurrentSub
type Cleanup = () -> () local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> ()
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...Cleanup?): () -> ()
local scopes: { [K]: () -> () } = {} local scopes: { [K]: () -> () } = {}
local currentItems: { [K]: V } = {} local currentItems: { [K]: V } = {}
local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V }) local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V })
for key in previousItems do for key in previousItems do
if currentItems[key] == nil then if currentItems[key] == nil and scopes[key] then
local dispose = scopes[key] scopes[key]()
scopes[key] = nil scopes[key] = nil
dispose()
end end
end end
@ -46,13 +42,7 @@ local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key
currentItems = selector() currentItems = selector()
if currentItems ~= previousItems then if currentItems ~= previousItems then
local currentSub = setCurrentSub(nil) updateScopes(currentItems, previousItems)
local success, result: unknown = pcall(updateScopes, currentItems, previousItems)
setCurrentSub(currentSub)
if not success then
error(`observe() encountered an error: {result}`, 2)
end
end end
end) end)

View file

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

View file

@ -1,26 +1,21 @@
local signals = require("./signals") local signals = require("./signals")
local computed = signals.computed
local effect = signals.effect local effect = signals.effect
local setCurrentSub = signals.setCurrentSub
local function subscribe<T>(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> () local function subscribe<T>(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> ()
local current: T local tracked = computed(function()
local mounted = true return selector()
end)
local current = tracked()
return effect(function() return effect(function()
local previous = current local previous = current
current = selector() current = tracked()
if mounted then if current ~= previous then
mounted = false listener(current, previous)
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
end end
end) end)
end end

View file

@ -1,4 +1,4 @@
--[=[ --[[
alien-signals 2.0.1 implementation adopted from alien-signals 2.0.1 implementation adopted from
https://github.com/Nicell/alien-signals-luau https://github.com/Nicell/alien-signals-luau
https://github.com/stackblitz/alien-signals https://github.com/stackblitz/alien-signals
@ -24,7 +24,7 @@
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
]=] ]]
export type ReactiveNode = { export type ReactiveNode = {
deps: Link?, 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 Selector<T = any> = () -> T
export type Atom<T = any> = ((((T) -> T) | T) -> T) & (() -> T) export type Atom<T = any> = Setter<T> & Getter<T>
return {} return {}

View file

@ -1,10 +1,11 @@
local noYield = require("./noYield")
local signals = require("./signals") local signals = require("./signals")
local setCurrentSub = signals.setCurrentSub local setCurrentSub = signals.setCurrentSub
local function untracked<T, Args...>(callback: ((Args...) -> T) | (Args...) -> (), ...: Args...): T local function untracked<T, Args...>(callback: ((Args...) -> T) | (Args...) -> (), ...: Args...): T
local currentSub = setCurrentSub(nil) local currentSub = setCurrentSub(nil)
local success, result = pcall(callback, ...) local success, result = noYield(callback, ...)
setCurrentSub(currentSub) setCurrentSub(currentSub)
if not success then 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 Charm = require("../src/lib")
local suite = require("@tests/suite") local suite = require("@test/suite")
local atom = Charm.atom local atom = Charm.atom
local listen = Charm.listen 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") assert(current == 2 and previous == 1, "should not run after unsub")
end) 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 {} return {}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,19 +1,20 @@
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
end)
end
require("./signals/spec") require("./signals/spec")
spec("gc") suite.describe("charm", function()
spec("listen") suite.describe("listen", function()
spec("mapped") require("./listen.spec")
spec("observe") end)
spec("subscribe") 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 {} return {}

View file

@ -1,11 +1,12 @@
local Charm = require("../src/lib") local Charm = require("../src/lib")
local suite = require("@tests/suite") local suite = require("@test/suite")
type Atom<T> = Charm.Atom<T> type Atom<T> = Charm.Atom<T>
local atom = Charm.atom local atom = Charm.atom
local computed = Charm.computed local computed = Charm.computed
local subscribe = Charm.subscribe local subscribe = Charm.subscribe
local listen = Charm.listen
local test = suite.test local test = suite.test
test("should subscribe to selector", function() 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)}`) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
end) 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 {} return {}

View file

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

View file

@ -2,16 +2,13 @@ local ServerScriptService = game:GetService("ServerScriptService")
local suite = require(ServerScriptService.Tests.suite) local suite = require(ServerScriptService.Tests.suite)
local describe = suite.describe suite.describe("react-charm", function()
suite.describe("useAtom", function()
local function spec(name: string) require("./useAtom.spec")
describe(name, function() end)
return require(`./{name}.spec`) :: any suite.describe("useAtomBinding", function()
require("./useAtomBinding.spec")
end) end)
end
describe("react-charm", function()
spec("useAtom")
end) end)
return {} 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 source = Vide.source
local cleanup = Vide.cleanup local cleanup = Vide.cleanup
--[=[ --- Subscribes to the state of an atom or selector. Returns a source that
Subscribes to the state of an atom and returns a Vide source. --- updates when the state changes.
---
@param callback The atom or selector to subscribe to. --- @param selector The atom or selector to subscribe to.
@return The reactive source. --- @return The reactive source.
]=] local function useAtom<State>(selector: () -> State): () -> State
local function useAtom<State>(callback: () -> State): () -> State local state = source(selector())
local state = source(callback()) cleanup(subscribe(selector, state))
cleanup(subscribe(callback, state))
return state return state
end end

View file

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

View file

@ -1,4 +1,4 @@
local suite = require("@tests/suite") local suite = require("@test/suite")
if not table.isfrozen(_G) then if not table.isfrozen(_G) then
_G.__DEV__ = true _G.__DEV__ = true

View file

@ -42,13 +42,13 @@ local color = {
return if options.colors then `\27[90;1m{text}\27[0m` else text return if options.colors then `\27[90;1m{text}\27[0m` else text
end, end,
green = function(text: string) green = function(text: string)
return if options.colors then `\27[32;1m{text}\27[0m` else text return if options.colors then `\27[32;1m{text}\27[0m` else `\u{1F7E2} {text}`
end, end,
red = function(text: string) red = function(text: string)
return if options.colors then `\27[31;1m{text}\27[0m` else text return if options.colors then `\27[31;1m{text}\27[0m` else `\u{1F534} {text}`
end, end,
blue = function(text: string) blue = function(text: string)
return if options.colors then `\27[34;1m{text}\27[0m` else text return if options.colors then `\27[34;1m{text}\27[0m` else `\u{1F535} {text}`
end, end,
} }
@ -405,27 +405,33 @@ local function finish(config: Config?)
report() report()
end end
local testImpl = setmetatable({ local testCtor = setmetatable({
skip = testSkip, skip = testSkip,
only = testOnly, only = testOnly,
}, { }, {
__call = function(_, name: string, run: () -> ()) __call = function(_, ...)
return test(name, run) return test(...)
end, end,
}) })
local describeImpl = setmetatable({ local describeCtor = setmetatable({
skip = describeSkip, skip = describeSkip,
only = describeOnly, only = describeOnly,
}, { }, {
__call = function(_, name: string, run: () -> ()) __call = function(_, ...)
return describe(name, run) return describe(...)
end, end,
}) })
return { return {
test = testImpl, test = testCtor :: ((name: string, callback: () -> ()) -> ()) & {
describe = describeImpl, skip: (name: string, callback: () -> ()) -> (),
only: (name: string, callback: () -> ()) -> (),
},
describe = describeCtor :: ((name: string, callback: () -> ()) -> ()) & {
skip: (name: string, callback: () -> ()) -> (),
only: (name: string, callback: () -> ()) -> (),
},
skip = skip, skip = skip,
only = only, only = only,
finish = finish, finish = finish,