diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b357e8..3e92647 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: run: sh bin/check.sh - name: Run tests - run: lute tests/init.luau + run: lute test/init.luau - name: Build models run: sh bin/build.sh diff --git a/.luaurc b/.luaurc index 7199120..a3678db 100644 --- a/.luaurc +++ b/.luaurc @@ -4,7 +4,7 @@ "LocalShadow": false }, "aliases": { - "tests": "tests", + "test": "test", "pkgs": "pkgs" } } diff --git a/README.md b/README.md index f832d4d..e3a421f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- Logo + Logo

Charm

diff --git a/images/logo.png b/assets/logo.png similarity index 100% rename from images/logo.png rename to assets/logo.png diff --git a/benchmarks/bench.luau b/benches/bench.luau similarity index 100% rename from benchmarks/bench.luau rename to benches/bench.luau diff --git a/benchmarks/mapped.luau b/benches/mapped.luau similarity index 100% rename from benchmarks/mapped.luau rename to benches/mapped.luau diff --git a/benchmarks/patch.bench.luau b/benches/patch.bench.luau similarity index 100% rename from benchmarks/patch.bench.luau rename to benches/patch.bench.luau diff --git a/benches/propagate.luau b/benches/propagate.luau new file mode 100644 index 0000000..27b1aed --- /dev/null +++ b/benches/propagate.luau @@ -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 diff --git a/benchmarks/propagate.luau b/benchmarks/propagate.luau deleted file mode 100644 index a08e8b1..0000000 --- a/benchmarks/propagate.luau +++ /dev/null @@ -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 diff --git a/bin/check.sh b/bin/check.sh index 8cf8dba..f3cccd4 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -7,10 +7,10 @@ luau-lsp analyze \ --flag:LuauFixIndexerSubtypingOrdering=true \ --sourcemap=sourcemap.json \ --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 diff --git a/default.project.json b/default.project.json index 904e9cd..1348524 100644 --- a/default.project.json +++ b/default.project.json @@ -15,14 +15,14 @@ "ServerScriptService": { "$className": "ServerScriptService", "Tests": { - "$path": "tests", + "$path": "test", "Charm": { "$path": "pkgs/charm/tests" }, "CharmSync": { "$path": "pkgs/charm-sync/tests" }, "ReactCharm": { "$path": "pkgs/react-charm/tests" }, "VideCharm": { "$path": "pkgs/vide-charm/tests" } }, "Benchmarks": { - "$path": "benchmarks" + "$path": "benches" } } } diff --git a/pkgs/charm-sync/tests/spec.luau b/pkgs/charm-sync/tests/spec.luau index 39ee48c..725714b 100644 --- a/pkgs/charm-sync/tests/spec.luau +++ b/pkgs/charm-sync/tests/spec.luau @@ -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 {} diff --git a/pkgs/charm/src/atom.luau b/pkgs/charm/src/atom.luau index 7e7eccb..ec70d79 100644 --- a/pkgs/charm/src/atom.luau +++ b/pkgs/charm/src/atom.luau @@ -2,51 +2,46 @@ local signals = require("./signals") local types = require("./types") type Atom = types.Atom +type Getter = types.Getter +type Setter = types.Setter +type Update = types.Update local signal = signals.signal -local function atomGetterSetterEquals( - source: () -> T, - setSource: (((T) -> T) | T) -> T, - equals: (T, T) -> boolean -): Atom - return function(...: ((T) -> T) | T): T +type Equals = (current: T, incoming: T) -> boolean + +local function createAtomWithEqualityCheck(getter: Getter, setter: Setter, equals: Equals): Atom + local function atom(...: Update): 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(source: () -> T, setSource: (((T) -> T) | T) -> T): Atom - return function(...: ((T) -> T) | T): T - if select("#", ...) == 0 then - return source() - else - return setSource((...)) - end +local function createAtom(getter: Getter, setter: Setter): Atom + local function atom(...: Update): T + return if select("#", ...) == 0 then getter() else setter((...)) end + + return atom end -local function atom(state: T, equals: ((previous: T, current: T) -> boolean)?): Atom - local source, setSource = signal(state) +local function atom(state: T, equals: Equals?): Atom + 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 diff --git a/pkgs/charm/src/effect.luau b/pkgs/charm/src/effect.luau index 572891e..2eb0802 100644 --- a/pkgs/charm/src/effect.luau +++ b/pkgs/charm/src/effect.luau @@ -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 diff --git a/pkgs/charm/src/effectScope.luau b/pkgs/charm/src/effectScope.luau index 7327d9a..7a40ee2 100644 --- a/pkgs/charm/src/effectScope.luau +++ b/pkgs/charm/src/effectScope.luau @@ -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 diff --git a/pkgs/charm/src/init.luau b/pkgs/charm/src/init.luau index c3dec01..bc510ce 100644 --- a/pkgs/charm/src/init.luau +++ b/pkgs/charm/src/init.luau @@ -1,6 +1,9 @@ local lib = require(script.lib) export type Atom = lib.Atom +export type Getter = lib.Getter export type Selector = lib.Selector +export type Setter = lib.Setter +export type Update = lib.Update return lib diff --git a/pkgs/charm/src/lib.luau b/pkgs/charm/src/lib.luau index 479af84..bae8330 100644 --- a/pkgs/charm/src/lib.luau +++ b/pkgs/charm/src/lib.luau @@ -10,7 +10,10 @@ local types = require("./types") local untracked = require("./untracked") export type Atom = types.Atom +export type Getter = types.Getter export type Selector = types.Selector +export type Setter = types.Setter +export type Update = types.Update local computed = signals.computed local effectScope = signals.effectScope diff --git a/pkgs/charm/src/listen.luau b/pkgs/charm/src/listen.luau index c72b061..816c165 100644 --- a/pkgs/charm/src/listen.luau +++ b/pkgs/charm/src/listen.luau @@ -1,32 +1,27 @@ local signals = require("./signals") +local computed = signals.computed local effect = signals.effect -local setCurrentSub = signals.setCurrentSub local function listen(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 diff --git a/pkgs/charm/src/mapped.luau b/pkgs/charm/src/mapped.luau index fa4f7f7..847968c 100644 --- a/pkgs/charm/src/mapped.luau +++ b/pkgs/charm/src/mapped.luau @@ -1,3 +1,4 @@ +local noYield = require("./noYield") local signals = require("./signals") local computed = signals.computed @@ -17,14 +18,18 @@ local function mapped(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(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 diff --git a/pkgs/charm/src/noYield.luau b/pkgs/charm/src/noYield.luau new file mode 100644 index 0000000..2d381b1 --- /dev/null +++ b/pkgs/charm/src/noYield.luau @@ -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(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 diff --git a/pkgs/charm/src/observe.luau b/pkgs/charm/src/observe.luau index 828ea0f..108b969 100644 --- a/pkgs/charm/src/observe.luau +++ b/pkgs/charm/src/observe.luau @@ -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(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...Cleanup?): () -> () +local function observe(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(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) diff --git a/pkgs/charm/src/signals.luau b/pkgs/charm/src/signals.luau index bb27e45..a1f4ae0 100644 --- a/pkgs/charm/src/signals.luau +++ b/pkgs/charm/src/signals.luau @@ -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 = 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(this: Signal): (() -> 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 diff --git a/pkgs/charm/src/subscribe.luau b/pkgs/charm/src/subscribe.luau index 01c4a98..6f5b84d 100644 --- a/pkgs/charm/src/subscribe.luau +++ b/pkgs/charm/src/subscribe.luau @@ -1,26 +1,21 @@ local signals = require("./signals") +local computed = signals.computed local effect = signals.effect -local setCurrentSub = signals.setCurrentSub local function subscribe(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 diff --git a/pkgs/charm/src/system.luau b/pkgs/charm/src/system.luau index 79d3a48..0c6334b 100644 --- a/pkgs/charm/src/system.luau +++ b/pkgs/charm/src/system.luau @@ -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?, diff --git a/pkgs/charm/src/types.luau b/pkgs/charm/src/types.luau index 20aa1f5..b237054 100644 --- a/pkgs/charm/src/types.luau +++ b/pkgs/charm/src/types.luau @@ -1,5 +1,9 @@ +export type Getter = () -> T +export type Setter = (update: Update) -> T +export type Update = T | (previous: T) -> T + export type Selector = () -> T -export type Atom = ((((T) -> T) | T) -> T) & (() -> T) +export type Atom = Setter & Getter return {} diff --git a/pkgs/charm/src/untracked.luau b/pkgs/charm/src/untracked.luau index ac77e85..4330920 100644 --- a/pkgs/charm/src/untracked.luau +++ b/pkgs/charm/src/untracked.luau @@ -1,10 +1,11 @@ +local noYield = require("./noYield") local signals = require("./signals") local setCurrentSub = signals.setCurrentSub local function untracked(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 diff --git a/pkgs/charm/tests/gc.spec.luau b/pkgs/charm/tests/gc.spec.luau deleted file mode 100644 index 033a66f..0000000 --- a/pkgs/charm/tests/gc.spec.luau +++ /dev/null @@ -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(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 {} diff --git a/pkgs/charm/tests/listen.spec.luau b/pkgs/charm/tests/listen.spec.luau index 15d7457..a244a45 100644 --- a/pkgs/charm/tests/listen.spec.luau +++ b/pkgs/charm/tests/listen.spec.luau @@ -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 {} diff --git a/pkgs/charm/tests/mapped.spec.luau b/pkgs/charm/tests/mapped.spec.luau index 7bc4a0c..6d623bc 100644 --- a/pkgs/charm/tests/mapped.spec.luau +++ b/pkgs/charm/tests/mapped.spec.luau @@ -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 diff --git a/pkgs/charm/tests/observe.spec.luau b/pkgs/charm/tests/observe.spec.luau index 57da889..ddceca9 100644 --- a/pkgs/charm/tests/observe.spec.luau +++ b/pkgs/charm/tests/observe.spec.luau @@ -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 diff --git a/pkgs/charm/tests/signals/computed.spec.luau b/pkgs/charm/tests/signals/computed.spec.luau index 3a4721a..5c74738 100644 --- a/pkgs/charm/tests/signals/computed.spec.luau +++ b/pkgs/charm/tests/signals/computed.spec.luau @@ -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 diff --git a/pkgs/charm/tests/signals/effect.spec.luau b/pkgs/charm/tests/signals/effect.spec.luau index a0d70a2..9fad3ce 100644 --- a/pkgs/charm/tests/signals/effect.spec.luau +++ b/pkgs/charm/tests/signals/effect.spec.luau @@ -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 diff --git a/pkgs/charm/tests/signals/effectScope.spec.luau b/pkgs/charm/tests/signals/effectScope.spec.luau index 711f785..e48dcbe 100644 --- a/pkgs/charm/tests/signals/effectScope.spec.luau +++ b/pkgs/charm/tests/signals/effectScope.spec.luau @@ -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 diff --git a/pkgs/charm/tests/signals/gc.spec.luau b/pkgs/charm/tests/signals/gc.spec.luau index e08d265..2179894 100644 --- a/pkgs/charm/tests/signals/gc.spec.luau +++ b/pkgs/charm/tests/signals/gc.spec.luau @@ -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) diff --git a/pkgs/charm/tests/signals/issue_48.spec.luau b/pkgs/charm/tests/signals/issue_48.spec.luau index 5089a84..a60b79d 100644 --- a/pkgs/charm/tests/signals/issue_48.spec.luau +++ b/pkgs/charm/tests/signals/issue_48.spec.luau @@ -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 diff --git a/pkgs/charm/tests/signals/spec.luau b/pkgs/charm/tests/signals/spec.luau index 15c69f5..7d5bdd7 100644 --- a/pkgs/charm/tests/signals/spec.luau +++ b/pkgs/charm/tests/signals/spec.luau @@ -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 {} diff --git a/pkgs/charm/tests/signals/topology.spec.luau b/pkgs/charm/tests/signals/topology.spec.luau index 070b30c..7fa961d 100644 --- a/pkgs/charm/tests/signals/topology.spec.luau +++ b/pkgs/charm/tests/signals/topology.spec.luau @@ -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 diff --git a/pkgs/charm/tests/signals/untrack.spec.luau b/pkgs/charm/tests/signals/untrack.spec.luau index 56a28ae..f8f738f 100644 --- a/pkgs/charm/tests/signals/untrack.spec.luau +++ b/pkgs/charm/tests/signals/untrack.spec.luau @@ -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 diff --git a/pkgs/charm/tests/spec.luau b/pkgs/charm/tests/spec.luau index f86a562..40c00f1 100644 --- a/pkgs/charm/tests/spec.luau +++ b/pkgs/charm/tests/spec.luau @@ -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 {} diff --git a/pkgs/charm/tests/subscribe.spec.luau b/pkgs/charm/tests/subscribe.spec.luau index c2d5bd5..c5a3907 100644 --- a/pkgs/charm/tests/subscribe.spec.luau +++ b/pkgs/charm/tests/subscribe.spec.luau @@ -1,11 +1,12 @@ local Charm = require("../src/lib") -local suite = require("@tests/suite") +local suite = require("@test/suite") type Atom = Charm.Atom 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 {} diff --git a/pkgs/react-charm/src/init.luau b/pkgs/react-charm/src/init.luau index 9d13132..9293a5f 100644 --- a/pkgs/react-charm/src/init.luau +++ b/pkgs/react-charm/src/init.luau @@ -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(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(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(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, } diff --git a/pkgs/react-charm/tests/spec.luau b/pkgs/react-charm/tests/spec.luau index 2ce9682..dabaab7 100644 --- a/pkgs/react-charm/tests/spec.luau +++ b/pkgs/react-charm/tests/spec.luau @@ -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 {} diff --git a/pkgs/react-charm/tests/useAtomBinding.spec.luau b/pkgs/react-charm/tests/useAtomBinding.spec.luau new file mode 100644 index 0000000..28dcad2 --- /dev/null +++ b/pkgs/react-charm/tests/useAtomBinding.spec.luau @@ -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 {} diff --git a/pkgs/vide-charm/src/init.luau b/pkgs/vide-charm/src/init.luau index ef7bb25..805a634 100644 --- a/pkgs/vide-charm/src/init.luau +++ b/pkgs/vide-charm/src/init.luau @@ -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(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(selector: () -> State): () -> State + local state = source(selector()) + cleanup(subscribe(selector, state)) return state end diff --git a/pkgs/vide-charm/tests/spec.luau b/pkgs/vide-charm/tests/spec.luau index 2bde270..4583308 100644 --- a/pkgs/vide-charm/tests/spec.luau +++ b/pkgs/vide-charm/tests/spec.luau @@ -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 {} diff --git a/tests/init.luau b/test/init.luau similarity index 79% rename from tests/init.luau rename to test/init.luau index 0b17cbc..467958e 100644 --- a/tests/init.luau +++ b/test/init.luau @@ -1,4 +1,4 @@ -local suite = require("@tests/suite") +local suite = require("@test/suite") if not table.isfrozen(_G) then _G.__DEV__ = true diff --git a/tests/spec.server.luau b/test/spec.server.luau similarity index 100% rename from tests/spec.server.luau rename to test/spec.server.luau diff --git a/tests/suite.luau b/test/suite.luau similarity index 92% rename from tests/suite.luau rename to test/suite.luau index e23b9d4..516db20 100644 --- a/tests/suite.luau +++ b/test/suite.luau @@ -42,13 +42,13 @@ local color = { return if options.colors then `\27[90;1m{text}\27[0m` else text end, 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, 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, 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, } @@ -405,27 +405,33 @@ local function finish(config: Config?) report() end -local testImpl = setmetatable({ +local testCtor = setmetatable({ skip = testSkip, only = testOnly, }, { - __call = function(_, name: string, run: () -> ()) - return test(name, run) + __call = function(_, ...) + return test(...) end, }) -local describeImpl = setmetatable({ +local describeCtor = setmetatable({ skip = describeSkip, only = describeOnly, }, { - __call = function(_, name: string, run: () -> ()) - return describe(name, run) + __call = function(_, ...) + return describe(...) end, }) return { - test = testImpl, - describe = describeImpl, + test = testCtor :: ((name: string, callback: () -> ()) -> ()) & { + skip: (name: string, callback: () -> ()) -> (), + only: (name: string, callback: () -> ()) -> (), + }, + describe = describeCtor :: ((name: string, callback: () -> ()) -> ()) & { + skip: (name: string, callback: () -> ()) -> (), + only: (name: string, callback: () -> ()) -> (), + }, skip = skip, only = only, finish = finish,