diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35c0019..de89233 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: sh bin/test.sh + run: lute tests/init.luau - name: Build models run: sh bin/build.sh diff --git a/.gitignore b/.gitignore index f1dcc5c..ac9c72d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ /.vscode /build /roblox_packages -/packages/**/wally.lock +/pkgs/**/wally.lock /sourcemap.json *.tsbuildinfo *.rbxm diff --git a/.luaurc b/.luaurc index 4c8043d..e9cc27f 100644 --- a/.luaurc +++ b/.luaurc @@ -2,5 +2,9 @@ "languageMode": "strict", "lint": { "LocalShadow": false + }, + "aliases": { + "tests": "tests", + "pkgs": "packages" } } diff --git a/bin/build.sh b/bin/build.sh index be6fdf7..c228f96 100644 --- a/bin/build.sh +++ b/bin/build.sh @@ -1,5 +1,5 @@ mkdir build -rojo build packages/charm -o build/charm.rbxm -rojo build packages/charm-sync -o build/charm-sync.rbxm -rojo build packages/react-charm -o build/react-charm.rbxm -rojo build packages/vide-charm -o build/vide-charm.rbxm +rojo build pkgs/charm -o build/charm.rbxm +rojo build pkgs/charm-sync -o build/charm-sync.rbxm +rojo build pkgs/react-charm -o build/react-charm.rbxm +rojo build pkgs/vide-charm -o build/vide-charm.rbxm diff --git a/bin/test.sh b/bin/test.sh deleted file mode 100644 index e1b757a..0000000 --- a/bin/test.sh +++ /dev/null @@ -1 +0,0 @@ -lute tests/spec.server.luau diff --git a/default.project.json b/default.project.json index cbd3b70..576c28c 100644 --- a/default.project.json +++ b/default.project.json @@ -6,15 +6,26 @@ "$className": "ReplicatedStorage", "Packages": { "$path": "roblox_packages", - "Charm": { "$path": "packages/charm" }, - "CharmSync": { "$path": "packages/charm-sync" }, - "ReactCharm": { "$path": "packages/react-charm" }, - "VideCharm": { "$path": "packages/vide-charm" } + "Charm": { "$path": "pkgs/charm" }, + "CharmSync": { "$path": "pkgs/charm-sync" }, + "ReactCharm": { "$path": "pkgs/react-charm" }, + "VideCharm": { "$path": "pkgs/vide-charm" } } }, "ServerScriptService": { "$className": "ServerScriptService", - "Tests": { "$path": "tests" } + "Tests": { + "$path": "tests", + "Charm": { "$path": "pkgs/charm/__tests__" }, + "CharmSync": { "$path": "pkgs/charm-sync/__tests__" }, + "ReactCharm": { "$path": "pkgs/react-charm/__tests__" }, + "VideCharm": { "$path": "pkgs/vide-charm/__tests__" } + }, + "Benchmarks": { + "$className": "Folder", + "Charm": { "$path": "pkgs/charm/__benchmarks__" }, + "CharmSync": { "$path": "pkgs/charm-sync/__benchmarks__" } + } } } } diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau deleted file mode 100644 index 9954123..0000000 --- a/packages/charm-sync/Charm.luau +++ /dev/null @@ -1,2 +0,0 @@ ---!nocheck -return require(script.Parent.charm.src) diff --git a/packages/charm/src/atom.luau b/packages/charm/src/atom.luau deleted file mode 100644 index 5cb0417..0000000 --- a/packages/charm/src/atom.luau +++ /dev/null @@ -1,54 +0,0 @@ -local signals = require("./signals") -local types = require("./types") - -type Atom = types.Atom -type AtomOptions = types.AtomOptions - -local signal = signals.signal -local pauseTracking = signals.pauseTracking -local resumeTracking = signals.resumeTracking - -local function atomOper(source: (...T) -> T, equals: ((T, T) -> boolean)?, ...: ((T) -> T) | T): T - if select("#", ...) == 0 then - return source() - end - - local transform = (...) - local currentState = (source :: any).value - local nextState: T - - -- If the transform is a function, call it with the current state and pause - -- tracking to avoid adding signals called by the transform. - if type(transform) == "function" then - pauseTracking() - local success, result = pcall(transform, currentState) - resumeTracking() - - if not success then - error(result, 2) - end - - nextState = result - else - nextState = transform - end - - if equals and equals(currentState, nextState) then - return nextState - end - - source(nextState) - - return nextState -end - -local function atom(state: T, options: AtomOptions?): Atom - local source = signal(state) - local equals = options and options.equals - - return function(...) - return atomOper(source, equals, ...) - end -end - -return atom diff --git a/packages/charm/src/effect.luau b/packages/charm/src/effect.luau deleted file mode 100644 index fc4f9bf..0000000 --- a/packages/charm/src/effect.luau +++ /dev/null @@ -1,27 +0,0 @@ -local signals = require("./signals") - -local effectImpl = signals.effect - -local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> () - local disposed = false - local dispose: () -> () - - local function disposeSafe() - disposed = true - if dispose then - dispose() - end - end - - dispose = effectImpl(function() - return callback(disposeSafe) - end) - - if disposed then - dispose() - end - - return disposeSafe -end - -return effect diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau deleted file mode 100644 index eaa2522..0000000 --- a/packages/charm/src/observe.luau +++ /dev/null @@ -1,38 +0,0 @@ -local listen = require("./listen") - -local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> () - local cleanups: { [K]: () -> ()? } = {} - - local unsub = listen(selector, function(currentItems, previousItems) - if not previousItems then - for key, value in currentItems do - cleanups[key] = observer(value, key) - end - return - end - - for key in previousItems do - local cleanup = cleanups[key] - if currentItems[key] == nil and cleanup then - cleanups[key] = nil - cleanup() - end - end - - for key, value in currentItems do - if previousItems[key] == nil then - cleanups[key] = observer(value, key) - end - end - end) - - return function() - unsub() - for _, cleanup in cleanups do - (cleanup :: () -> ())() - end - table.clear(cleanups) - end -end - -return observe diff --git a/packages/react-charm/Charm.luau b/packages/react-charm/Charm.luau deleted file mode 100644 index 9954123..0000000 --- a/packages/react-charm/Charm.luau +++ /dev/null @@ -1,2 +0,0 @@ ---!nocheck -return require(script.Parent.charm.src) diff --git a/packages/react-charm/React.luau b/packages/react-charm/React.luau deleted file mode 100644 index 3e8acbd..0000000 --- a/packages/react-charm/React.luau +++ /dev/null @@ -1,2 +0,0 @@ ---!nocheck -return require(script.Parent.react) diff --git a/packages/vide-charm/Charm.luau b/packages/vide-charm/Charm.luau deleted file mode 100644 index 9954123..0000000 --- a/packages/vide-charm/Charm.luau +++ /dev/null @@ -1,2 +0,0 @@ ---!nocheck -return require(script.Parent.charm.src) diff --git a/packages/vide-charm/Vide.luau b/packages/vide-charm/Vide.luau deleted file mode 100644 index 0dc1c09..0000000 --- a/packages/vide-charm/Vide.luau +++ /dev/null @@ -1,2 +0,0 @@ ---!nocheck -return require(script.Parent.vide.src) diff --git a/tests/benchmarks/patch.bench.luau b/pkgs/charm-sync/__benchmarks__/patch.bench.luau similarity index 78% rename from tests/benchmarks/patch.bench.luau rename to pkgs/charm-sync/__benchmarks__/patch.bench.luau index e353acd..8d4eeea 100644 --- a/tests/benchmarks/patch.bench.luau +++ b/pkgs/charm-sync/__benchmarks__/patch.bench.luau @@ -24,14 +24,18 @@ local function update(state) state.users = table.clone(state.users) for user = 1, USERS do - if math.random() > 0.5 then - continue - end + local rng = math.random() - state.users[user] = { - coins = math.random(1, 2), - gems = math.random(1, 2), - } + if rng < 1 / 3 then + continue + elseif rng < 2 / 3 then + state.users[user] = nil + else + state.users[user] = { + coins = math.random(1, 2), + gems = math.random(1, 2), + } + end end return state diff --git a/tests/charm-sync/client.spec.luau b/pkgs/charm-sync/__tests__/client.spec.luau similarity index 100% rename from tests/charm-sync/client.spec.luau rename to pkgs/charm-sync/__tests__/client.spec.luau diff --git a/tests/charm-sync/flatten.spec.luau b/pkgs/charm-sync/__tests__/flatten.spec.luau similarity index 100% rename from tests/charm-sync/flatten.spec.luau rename to pkgs/charm-sync/__tests__/flatten.spec.luau diff --git a/tests/charm-sync/patch.spec.luau b/pkgs/charm-sync/__tests__/patch.spec.luau similarity index 100% rename from tests/charm-sync/patch.spec.luau rename to pkgs/charm-sync/__tests__/patch.spec.luau diff --git a/tests/charm-sync/server.spec.luau b/pkgs/charm-sync/__tests__/server.spec.luau similarity index 100% rename from tests/charm-sync/server.spec.luau rename to pkgs/charm-sync/__tests__/server.spec.luau diff --git a/pkgs/charm-sync/__tests__/spec.luau b/pkgs/charm-sync/__tests__/spec.luau new file mode 100644 index 0000000..b386f15 --- /dev/null +++ b/pkgs/charm-sync/__tests__/spec.luau @@ -0,0 +1,19 @@ +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 + end) +end + +spec("client") +spec("server") +spec("flatten") +spec("patch") +spec("validate") + +return {} diff --git a/tests/charm-sync/validate.spec.luau b/pkgs/charm-sync/__tests__/validate.spec.luau similarity index 100% rename from tests/charm-sync/validate.spec.luau rename to pkgs/charm-sync/__tests__/validate.spec.luau diff --git a/packages/charm-sync/default.project.json b/pkgs/charm-sync/default.project.json similarity index 100% rename from packages/charm-sync/default.project.json rename to pkgs/charm-sync/default.project.json diff --git a/packages/charm-sync/src/client.luau b/pkgs/charm-sync/src/client.luau similarity index 100% rename from packages/charm-sync/src/client.luau rename to pkgs/charm-sync/src/client.luau diff --git a/packages/charm-sync/src/flatten.luau b/pkgs/charm-sync/src/flatten.luau similarity index 100% rename from packages/charm-sync/src/flatten.luau rename to pkgs/charm-sync/src/flatten.luau diff --git a/packages/charm-sync/src/init.luau b/pkgs/charm-sync/src/init.luau similarity index 100% rename from packages/charm-sync/src/init.luau rename to pkgs/charm-sync/src/init.luau diff --git a/packages/charm-sync/src/interval.luau b/pkgs/charm-sync/src/interval.luau similarity index 100% rename from packages/charm-sync/src/interval.luau rename to pkgs/charm-sync/src/interval.luau diff --git a/packages/charm-sync/src/patch.luau b/pkgs/charm-sync/src/patch.luau similarity index 100% rename from packages/charm-sync/src/patch.luau rename to pkgs/charm-sync/src/patch.luau diff --git a/packages/charm-sync/src/server.luau b/pkgs/charm-sync/src/server.luau similarity index 100% rename from packages/charm-sync/src/server.luau rename to pkgs/charm-sync/src/server.luau diff --git a/packages/charm-sync/src/types.luau b/pkgs/charm-sync/src/types.luau similarity index 100% rename from packages/charm-sync/src/types.luau rename to pkgs/charm-sync/src/types.luau diff --git a/packages/charm-sync/src/validate.luau b/pkgs/charm-sync/src/validate.luau similarity index 100% rename from packages/charm-sync/src/validate.luau rename to pkgs/charm-sync/src/validate.luau diff --git a/packages/charm-sync/wally.toml b/pkgs/charm-sync/wally.toml similarity index 100% rename from packages/charm-sync/wally.toml rename to pkgs/charm-sync/wally.toml diff --git a/tests/benchmarks/effect.bench.luau b/pkgs/charm/__benchmarks__/effect.bench.luau similarity index 81% rename from tests/benchmarks/effect.bench.luau rename to pkgs/charm/__benchmarks__/effect.bench.luau index 10d0643..45ab9c7 100644 --- a/tests/benchmarks/effect.bench.luau +++ b/pkgs/charm/__benchmarks__/effect.bench.luau @@ -6,17 +6,12 @@ local computed = Charm.computed local effect = Charm.effect local atom = Charm.atom -local cleanup: () -> () - return { - AfterEach = function() - cleanup() - end, Functions = { atom = function() local state = atom(0) - cleanup = effect(function() + effect(function() state() end) @@ -32,7 +27,7 @@ return { return 2 * state() end) - cleanup = effect(function() + effect(function() double() end) diff --git a/pkgs/charm/__benchmarks__/propagate.bench.luau b/pkgs/charm/__benchmarks__/propagate.bench.luau new file mode 100644 index 0000000..e2b0260 --- /dev/null +++ b/pkgs/charm/__benchmarks__/propagate.bench.luau @@ -0,0 +1,63 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.Packages.Charm) + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local listen = Charm.listen + +local function propagate(w: number, h: number) + 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 + +local function propagateListen(w: number, h: number) + 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 + listen(last, function() end) + end + src(src() + 1) +end + +return { + Functions = { + ["effect 1*100"] = function() + propagate(1, 100) + end, + ["effect 100*1"] = function() + propagate(100, 1) + end, + ["effect 10*10"] = function() + propagate(10, 10) + end, + ["listen 1*100"] = function() + propagateListen(1, 100) + end, + ["listen 100*1"] = function() + propagateListen(100, 1) + end, + ["listen 10*10"] = function() + propagateListen(10, 10) + end, + }, +} diff --git a/tests/signals/computed.spec.luau b/pkgs/charm/__tests__/signals/computed.spec.luau similarity index 79% rename from tests/signals/computed.spec.luau rename to pkgs/charm/__tests__/signals/computed.spec.luau index 828d9c3..3a4721a 100644 --- a/tests/signals/computed.spec.luau +++ b/pkgs/charm/__tests__/signals/computed.spec.luau @@ -1,13 +1,12 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.signals local computed = signals.computed local signal = signals.signal local test = suite.test test("should correctly propagate changes through computed signals", function() - local src = signal(0) + local src, setSrc = signal(0) local c1 = computed(function() return src() % 2 end) @@ -19,15 +18,15 @@ test("should correctly propagate changes through computed signals", function() end) c3() - src(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty + setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty c2() -- c1 -> none, c2 -> none - src(3) -- c1 -> dirty, c2 -> toCheckDirty + setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty assert(c3() == 1, "bad final value") end) test("should propagate updated source value through chained computations", function() - local src = signal(0) + local src, setSrc = signal(0) local a = computed(function() return src() end) @@ -42,12 +41,12 @@ test("should propagate updated source value through chained computations", funct end) assert(d() == 0, "bad initial value") - src(2) + setSrc(2) assert(d() == 2, "bad updated value") end) test("should handle flags are indirectly updated during checkDirty", function() - local a = signal(false) + local a, setA = signal(false) local b = computed(function() return a() end) @@ -61,22 +60,22 @@ test("should handle flags are indirectly updated during checkDirty", function() end) assert(d() == false, "bad initial value") - a(true) + setA(true) assert(d() == true, "bad updated value") end) test("should not update if the signal value is reverted", function() local times = 0 - local src = signal(0) + local src, setSrc = signal(0) local c1 = computed(function() times += 1 return src() end) c1() assert(times == 1, "computed did not run") - src(1) - src(0) + setSrc(1) + setSrc(0) c1() assert(times == 1, "computed ran when it should not") end) diff --git a/tests/signals/effect.spec.luau b/pkgs/charm/__tests__/signals/effect.spec.luau similarity index 75% rename from tests/signals/effect.spec.luau rename to pkgs/charm/__tests__/signals/effect.spec.luau index a279e47..7fb2546 100644 --- a/tests/signals/effect.spec.luau +++ b/pkgs/charm/__tests__/signals/effect.spec.luau @@ -1,13 +1,13 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.signals local computed = signals.computed local effect = signals.effect local effectScope = signals.effectScope local endBatch = signals.endBatch local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking +local onEffectCleanup = signals.onEffectCleanup local signal = signals.signal local startBatch = signals.startBatch local test = suite.test @@ -15,7 +15,7 @@ local test = suite.test test("should clear subscriptions when untracked by all subscribers", function() local bRunTimes = 0 - local a = signal(1) + local a, setA = signal(1) local b = computed(function() bRunTimes += 1 return a() * 2 @@ -25,15 +25,15 @@ test("should clear subscriptions when untracked by all subscribers", function() end) assert(bRunTimes == 1, "should run initially") - a(2) + setA(2) assert(bRunTimes == 2, "should run after change") stopEffect() - a(3) + setA(3) assert(bRunTimes == 2, "should not run after stop") end) test("should not run untracked inner effect", function() - local a = signal(3) + local a, setA = signal(3) local b = computed(function() return a() > 0 end) @@ -48,14 +48,14 @@ test("should not run untracked inner effect", function() end end) - a(2) - a(1) - a(0) + setA(2) + setA(1) + setA(0) end) test("should run outer effect first", function() - local a = signal(1) - local b = signal(1) + local a, setA = signal(1) + local b, setB = signal(1) effect(function() if a() > 0 then @@ -69,13 +69,13 @@ test("should run outer effect first", function() end) startBatch() - b(0) - a(0) + setB(0) + setA(0) endBatch() end) test("should not trigger inner effect when resolve maybe dirty", function() - local a = signal(0) + local a, setA = signal(0) local b = computed(function() return a() % 2 end) @@ -92,12 +92,12 @@ test("should not trigger inner effect when resolve maybe dirty", function() end) end) - a(2) + setA(2) end) test("should trigger inner effects in sequence", function() - local a = signal(0) - local b = signal(0) + local a, setA = signal(0) + local b, setB = signal(0) local c = computed(function() return a() - b() end) @@ -121,16 +121,16 @@ test("should trigger inner effects in sequence", function() table.clear(order) startBatch() - b(1) - a(1) + setB(1) + setA(1) endBatch() assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`) end) test("should trigger inner effects in sequence in effect scope", function() - local a = signal(0) - local b = signal(0) + local a, setA = signal(0) + local b, setB = signal(0) local order: { string } = {} effectScope(function() @@ -149,8 +149,8 @@ test("should trigger inner effects in sequence in effect scope", function() table.clear(order) startBatch() - b(1) - a(1) + setB(1) + setA(1) endBatch() assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`) @@ -170,12 +170,12 @@ test("should custom effect support batch", function() local logs: { string } = {} local a = signal(0) - local b = signal(0) + local b, setB = signal(0) local aa = computed(function() table.insert(logs, "aa-0") if a() == 0 then - b(1) + setB(1) end table.insert(logs, "aa-1") return nil @@ -197,8 +197,8 @@ test("should custom effect support batch", function() end) test("should duplicate subscribers do not affect the notify order", function() - local src1 = signal(0) - local src2 = signal(0) + local src1, setSrc1 = signal(0) + local src2, setSrc2 = signal(0) local order: { string } = {} effect(function() @@ -216,17 +216,17 @@ test("should duplicate subscribers do not affect the notify order", function() table.insert(order, "b") src1() end) - src2(1) -- src1.subs: a -> b -> a + setSrc2(1) -- src1.subs: a -> b -> a table.clear(order) - src1(src1() + 1) + setSrc1(src1() + 1) assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) end) test("should handle side effect with inner effects", function() - local a = signal(0) - local b = signal(0) + local a, setA = signal(0) + local b, setB = signal(0) local order: { string } = {} effect(function() @@ -241,14 +241,14 @@ test("should handle side effect with inner effects", function() assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) table.clear(order) - b(1) - a(1) + setB(1) + setA(1) assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`) end) end) test("should handle flags are indirectly updated during checkDirty", function() - local a = signal(false) + local a, setA = signal(false) local b = computed(function() return a() end) @@ -268,71 +268,57 @@ test("should handle flags are indirectly updated during checkDirty", function() triggers += 1 end) assert(triggers == 1, "did not run initially") - a(true) + setA(true) assert(triggers == 2, "did not update indirectly") end) -test("should run effect cleanup", function() - local cleanups = 0 +test("should run cleanups in sequence", function() + local order = {} - local a = signal(false) - local cleanup = effect(function() + local a, setA = signal(false) + local stopEffect = effect(function() a() - return function() - cleanups += 1 - end - end) - - assert(cleanups == 0, "should not cleanup immediately") - a(true) - assert(cleanups == 1, "should cleanup once") - cleanup() - assert(cleanups == 2, "should cleanup after effect stop") - a(false) - assert(cleanups == 2, "should not cleanup after update") -end) - -test("should run inner effect cleanup in scope", function() - local cleanups = 0 - - local a = signal(false) - local stopScope = effectScope(function() - effect(function() - a() - return function() - cleanups += 1 - end + onEffectCleanup(function() + table.insert(order, "a") + end) + onEffectCleanup(function() + table.insert(order, "b") end) end) - assert(cleanups == 0, "should not cleanup immediately") - a(true) - assert(cleanups == 1, "should cleanup once") - stopScope() - assert(cleanups == 2, "should cleanup after outer dispose") - a(false) - assert(cleanups == 2, "should not cleanup after update") + assert(table.concat(order) == "", "should not cleanup immediately") + + setA(true) + assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`) + + table.clear(order) + stopEffect() + assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`) + + table.clear(order) + setA(false) + assert(table.concat(order) == "", "should not cleanup after update") end) test("should run inner effect cleanup", function() local cleanups = 0 - local a = signal(false) - local stopScope = effect(function() + local a, setA = signal(false) + local stopEffect = effect(function() a() effect(function() - return function() + onEffectCleanup(function() cleanups += 1 - end + end) end) end) assert(cleanups == 0, "should not cleanup immediately") - a(true) + setA(true) assert(cleanups == 1, "should cleanup after outer update") - stopScope() + stopEffect() assert(cleanups == 2, "should cleanup after outer dispose") - a(false) + setA(false) assert(cleanups == 2, "should not cleanup after update") end) diff --git a/tests/signals/effectScope.spec.luau b/pkgs/charm/__tests__/signals/effectScope.spec.luau similarity index 59% rename from tests/signals/effectScope.spec.luau rename to pkgs/charm/__tests__/signals/effectScope.spec.luau index 5f5ebd3..711f785 100644 --- a/tests/signals/effectScope.spec.luau +++ b/pkgs/charm/__tests__/signals/effectScope.spec.luau @@ -1,14 +1,15 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.signals local effect = signals.effect local effectScope = signals.effectScope local signal = signals.signal +local onEffectCleanup = signals.onEffectCleanup +local onScopeDispose = signals.onScopeDispose local test = suite.test test("should not trigger after stop", function() - local count = signal(1) + local count, setCount = signal(1) local triggers = 0 @@ -19,42 +20,26 @@ test("should not trigger after stop", function() end) assert(triggers == 1, "did not run initially") - count(2) + setCount(2) assert(triggers == 2, "did not run after update") end) - count(3) + setCount(3) assert(triggers == 3, "did not run after update outside scope") stopScope() - count(4) + setCount(4) assert(triggers == 3, "ran after stop") end) -test("should run effect cleanup", function() - local cleanups = 0 - - local cleanup = effectScope(function() - return function() - cleanups += 1 - end - end) - - assert(cleanups == 0, "should not cleanup immediately") - cleanup() - assert(cleanups == 1, "should cleanup after effect stop") - cleanup() - assert(cleanups == 1, "should skip redundant cleanup") -end) - -test("should run indirect effect cleanup", function() +test("should run indirect scope cleanup", function() local cleanups = 0 local cleanup local stopScope = effectScope(function() cleanup = effectScope(function() - return function() + onScopeDispose(function() cleanups += 1 - end + end) end) end) @@ -65,4 +50,26 @@ test("should run indirect effect cleanup", function() assert(cleanups == 1, "should skip redundant dispose") end) +test("should run indirect effect cleanup", function() + local cleanups = 0 + + local a, setA = signal(false) + local stopScope = effectScope(function() + effect(function() + a() + onEffectCleanup(function() + cleanups += 1 + end) + end) + end) + + assert(cleanups == 0, "should not cleanup immediately") + setA(true) + assert(cleanups == 1, "should cleanup once") + stopScope() + assert(cleanups == 2, "should cleanup after outer dispose") + setA(false) + assert(cleanups == 2, "should not cleanup after update") +end) + return {} diff --git a/tests/signals/issue_48.spec.luau b/pkgs/charm/__tests__/signals/issue_48.spec.luau similarity index 93% rename from tests/signals/issue_48.spec.luau rename to pkgs/charm/__tests__/signals/issue_48.spec.luau index bfb2d86..519c0aa 100644 --- a/tests/signals/issue_48.spec.luau +++ b/pkgs/charm/__tests__/signals/issue_48.spec.luau @@ -1,7 +1,6 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.signals local computed = signals.computed local effect = signals.effect local pauseTracking = signals.pauseTracking @@ -92,7 +91,7 @@ local function reaction( end test("#48", function() - local source = signal(0) + local source, setSource = signal(0) local disposeInner: () -> () reaction(function() @@ -107,9 +106,9 @@ test("#48", function() end end) - source(1) - source(2) - source(3) + setSource(1) + setSource(2) + setSource(3) end) return {} diff --git a/pkgs/charm/__tests__/signals/spec.luau b/pkgs/charm/__tests__/signals/spec.luau new file mode 100644 index 0000000..fcc4a57 --- /dev/null +++ b/pkgs/charm/__tests__/signals/spec.luau @@ -0,0 +1,18 @@ +local suite = require("@tests/suite") + +local describe = suite.describe + +local function spec(name: string) + describe(name, function() + return require(`./{name}.spec`) :: any + end) +end + +spec("computed") +spec("effect") +spec("effectScope") +spec("issue_48") +spec("topology") +spec("untrack") + +return {} diff --git a/tests/signals/topology.spec.luau b/pkgs/charm/__tests__/signals/topology.spec.luau similarity index 94% rename from tests/signals/topology.spec.luau rename to pkgs/charm/__tests__/signals/topology.spec.luau index e111576..070b30c 100644 --- a/tests/signals/topology.spec.luau +++ b/pkgs/charm/__tests__/signals/topology.spec.luau @@ -1,7 +1,6 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.signals local computed = signals.computed local effect = signals.effect local signal = signals.signal @@ -44,7 +43,7 @@ describe("graph updates", function() -- C -- | -- D - local a = signal(2) + local a, setA = signal(2) local b = computed(function() return a() - 1 @@ -65,7 +64,7 @@ describe("graph updates", function() assert(computeCalls == 1, "compute should be called once") computeCalls = 0 - a(4) + setA(4) d() assert(computeCalls == 1, "compute should drop update") end) @@ -79,7 +78,7 @@ describe("graph updates", function() -- \ / -- D - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -97,7 +96,7 @@ describe("graph updates", function() assert(d() == "a a", "d should be 'a a'") assert(spyCalls == 1, "compute should be called once") - a("aa") + setA("aa") assert(d() == "aa aa", "d should be 'aa aa'") assert(spyCalls == 2, "compute should be called only twice") end) @@ -112,7 +111,7 @@ describe("graph updates", function() -- | -- E - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -134,7 +133,7 @@ describe("graph updates", function() assert(e() == "a a", "e should be 'a a'") assert(spyCalls == 1, "compute should be called once") - a("aa") + setA("aa") assert(e() == "aa aa", "e should be 'aa aa'") assert(spyCalls == 2, "compute should be called only twice") end) @@ -142,7 +141,7 @@ describe("graph updates", function() test("should bail out if result is the same", function() -- Bail out if value of "B" never changes -- A->B->C - local a = signal("a") + local a, setA = signal("a") local b = computed(function() a() return "foo" @@ -158,7 +157,7 @@ describe("graph updates", function() assert(c() == "foo", "c should be 'foo'") assert(spyCalls == 1, "compute should be called once") - a("aa") + setA("aa") assert(c() == "foo", "c should be 'foo'") assert(spyCalls == 1, "compute should bail out") end) @@ -174,7 +173,7 @@ describe("graph updates", function() -- E -- / \ -- F G - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() @@ -223,7 +222,7 @@ describe("graph updates", function() fSpyCalls, fSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0 - a("b") + setA("b") assert(e() == "b b", "e should be 'b b'") assert(eSpyCalls == 1, "e should run once after update") @@ -238,7 +237,7 @@ describe("graph updates", function() fSpyCalls, fSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0 - a("c") + setA("c") assert(e() == "c c", "e should be 'c c'") assert(eSpyCalls == 1, "e should run once after update") @@ -259,7 +258,7 @@ describe("graph updates", function() -- *A -- / \ -- *B C <- we don't listen to C - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() @@ -274,7 +273,7 @@ describe("graph updates", function() assert(b() == "a", "b should be 'a'") assert(spyCalls == 0, "compute should not run") - a("aa") + setA("aa") assert(b() == "aa", "b should be 'aa'") assert(spyCalls == 0, "compute should not run") end) @@ -288,7 +287,7 @@ describe("graph updates", function() -- *B D <- we don't listen to C -- | -- *C - local a = signal("a") + local a, setA = signal("a") local spyBCalls = 0 local spyB = function() spyBCalls += 1 @@ -319,7 +318,7 @@ describe("graph updates", function() spyCCalls = 0 unsub() - a("aa") + setA("aa") assert(spyBCalls == 0, "b should not run") assert(spyCCalls == 0, "c should not run") @@ -336,7 +335,7 @@ describe("graph updates", function() -- B *C <- returns same value every time -- \ / -- D - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -354,7 +353,7 @@ describe("graph updates", function() assert(d() == "a c", "d should be 'a c'") spyResult = nil - a("aa") + setA("aa") d() assert(spyResult == "aa c", "d should have returned 'aa c'") end) @@ -368,7 +367,7 @@ describe("graph updates", function() -- B *C *D -- \ | / -- E - local a = signal("a") + local a, setA = signal("a") local b = computed(function() return a() end) @@ -390,13 +389,13 @@ describe("graph updates", function() assert(e() == "a c d", "e should be 'a c d'") spyResult = nil - a("aa") + setA("aa") e() assert(spyResult == "aa c d", "e should have returned 'aa c d'") end) test("should support lazy branches", function() - local a = signal(0) + local a, setA = signal(0) local b = computed(function() return a() end) @@ -405,10 +404,10 @@ describe("graph updates", function() end) assert(c() == 0, "c should be 0") - a(1) + setA(1) assert(c() == 1, "c should be 1") - a(0) + setA(0) assert(c() == 0, "c should be 0 again") end) @@ -420,7 +419,7 @@ describe("graph updates", function() -- *B *C -- \ / -- D - local a = signal("a") + local a, setA = signal("a") local b = computed(function() a() return "b" @@ -439,14 +438,14 @@ describe("graph updates", function() assert(d() == "b c", "d should be 'b c'") spyCalls = 0 - a("aa") + setA("aa") assert(spyCalls == 0, "d should not run") end) end) describe("error handling", function() test("should keep graph consistent on errors during activation", function() - local a = signal(0) + local a, setA = signal(0) local b = computed(function() error("fail") end) @@ -460,12 +459,12 @@ describe("error handling", function() assert(not success, "should throw") assert(string.match(result, "fail"), "should throw fail") - a(1) + setA(1) assert(c() == 1, "c should recover and return 1") end) test("should keep graph consistent on errors in computeds", function() - local a = signal(0) + local a, setA = signal(0) local b = computed(function() if a() == 1 then error("fail") @@ -478,14 +477,14 @@ describe("error handling", function() assert(c() == 0, "c should be 0") - a(1) + setA(1) local success, result = pcall(function() b() end) assert(not success, "should throw") assert(string.match(result, "fail"), "should throw fail") - a(2) + setA(2) assert(c() == 2, "c should recover and return 2") end) end) diff --git a/tests/signals/untrack.spec.luau b/pkgs/charm/__tests__/signals/untrack.spec.luau similarity index 77% rename from tests/signals/untrack.spec.luau rename to pkgs/charm/__tests__/signals/untrack.spec.luau index 860ea0b..c08c3e1 100644 --- a/tests/signals/untrack.spec.luau +++ b/pkgs/charm/__tests__/signals/untrack.spec.luau @@ -1,7 +1,6 @@ -local charm = require("../charm") -local suite = require("../suite") +local signals = require("../../src/signals") +local suite = require("@tests/suite") -local signals = charm.signals local computed = signals.computed local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking @@ -9,7 +8,7 @@ local signal = signals.signal local test = suite.test test("should pause tracking", function() - local src = signal(0) + local src, setSrc = signal(0) local c = computed(function() pauseTracking() local value = src() @@ -18,7 +17,7 @@ test("should pause tracking", function() end) assert(c() == 0, "should not track src") - src(1) + setSrc(1) assert(c() == 0, "should not track src") end) diff --git a/pkgs/charm/__tests__/spec.luau b/pkgs/charm/__tests__/spec.luau new file mode 100644 index 0000000..7ae1948 --- /dev/null +++ b/pkgs/charm/__tests__/spec.luau @@ -0,0 +1,20 @@ +local suite = require("@tests/suite") + +local describe = suite.describe + +local function collect(name: string) + describe(name, function() + return require(`./{name}/spec`) :: any + end) +end + +local function spec(name: string) + describe(name, function() + return require(`./{name}.spec`) :: any + end) +end + +collect("signals") +spec("subscribe") + +return {} diff --git a/pkgs/charm/__tests__/subscribe.spec.luau b/pkgs/charm/__tests__/subscribe.spec.luau new file mode 100644 index 0000000..1199594 --- /dev/null +++ b/pkgs/charm/__tests__/subscribe.spec.luau @@ -0,0 +1,28 @@ +local atom = require("../src/atom") +local subscribe = require("../src/subscribe") +local suite = require("@tests/suite") + +local test = suite.test + +test("should subscribe to atom", function() + local source = atom(0) + local current, previous + + local unsub = subscribe(source, function(a, b) + current = a + previous = b + end) + + assert(not current and not previous, "should not run initially") + source(1) + assert(current == 1, "should run with new value") + assert(previous == 0, "should run with old value") + source(2) + assert(current == 2, "should run with new value 2") + assert(previous == 1, "should run with old value 2") + unsub() + source(3) + assert(current == 2, "should not run after unsubscribing") +end) + +return {} diff --git a/packages/charm/default.project.json b/pkgs/charm/default.project.json similarity index 100% rename from packages/charm/default.project.json rename to pkgs/charm/default.project.json diff --git a/pkgs/charm/src/atom.luau b/pkgs/charm/src/atom.luau new file mode 100644 index 0000000..fc847e0 --- /dev/null +++ b/pkgs/charm/src/atom.luau @@ -0,0 +1,54 @@ +local signals = require("./signals") +local types = require("./types") + +type Atom = types.Atom +type AtomOptions = types.AtomOptions + +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 + if select("#", ...) == 0 then + return source() + 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 + end +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 + end +end + +local function atom(state: T, equals: ((previous: T, current: T) -> boolean)?): Atom + local source, setSource = signal(state) + + if equals then + return atomGetterSetterEquals(source, setSource, equals) + else + return atomGetterSetter(source, setSource) + end +end + +return atom diff --git a/packages/charm/src/batched.luau b/pkgs/charm/src/batched.luau similarity index 100% rename from packages/charm/src/batched.luau rename to pkgs/charm/src/batched.luau diff --git a/pkgs/charm/src/effect.luau b/pkgs/charm/src/effect.luau new file mode 100644 index 0000000..67551ec --- /dev/null +++ b/pkgs/charm/src/effect.luau @@ -0,0 +1,27 @@ +local signals = require("./signals") + +local _effect = signals.effect +local onEffectCleanup = signals.onEffectCleanup + +local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> () + local disposeImmediately = false + + local function dispose() + disposeImmediately = true + end + + dispose = _effect(function() + local cleanup = callback(dispose) + if cleanup then + onEffectCleanup(cleanup) + end + end) + + if disposeImmediately then + dispose() + end + + return dispose +end + +return effect diff --git a/pkgs/charm/src/effectScope.luau b/pkgs/charm/src/effectScope.luau new file mode 100644 index 0000000..a324bb9 --- /dev/null +++ b/pkgs/charm/src/effectScope.luau @@ -0,0 +1,15 @@ +local signals = require("./signals") + +local _effectScope = signals.effectScope +local onScopeDispose = signals.onScopeDispose + +local function effectScope(callback: () -> ...() -> ()?): () -> () + return _effectScope(function() + local cleanup = callback() + if cleanup then + onScopeDispose(cleanup) + end + end) +end + +return effectScope diff --git a/packages/charm/src/init.luau b/pkgs/charm/src/init.luau similarity index 100% rename from packages/charm/src/init.luau rename to pkgs/charm/src/init.luau diff --git a/packages/charm/src/lib.luau b/pkgs/charm/src/lib.luau similarity index 83% rename from packages/charm/src/lib.luau rename to pkgs/charm/src/lib.luau index 8741d13..adcfb67 100644 --- a/packages/charm/src/lib.luau +++ b/pkgs/charm/src/lib.luau @@ -15,6 +15,8 @@ export type Selector = types.Selector local computed = signals.computed local effectScope = signals.effectScope +local onEffectCleanup = signals.onEffectCleanup +local onScopeDispose = signals.onScopeDispose return { atom = atom, @@ -27,5 +29,7 @@ return { observe = observe, subscribe = subscribe, untracked = untracked, + onEffectCleanup = onEffectCleanup, + onScopeDispose = onScopeDispose, signals = signals, } diff --git a/packages/charm/src/listen.luau b/pkgs/charm/src/listen.luau similarity index 52% rename from packages/charm/src/listen.luau rename to pkgs/charm/src/listen.luau index e16e623..7a760e5 100644 --- a/packages/charm/src/listen.luau +++ b/pkgs/charm/src/listen.luau @@ -6,40 +6,31 @@ local pauseTracking = signals.pauseTracking local resumeTracking = signals.resumeTracking local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () - local disposed = false - local mounted = true + local disposeImmediately = false local current: T? - local dispose: () -> () - local function disposeSafe() - disposed = true - if dispose then - dispose() - end + local function dispose() + disposeImmediately = true end dispose = effect(function() local value = selector() - if mounted then - mounted = false - elseif value == current then - return + if value ~= current then + local previous = current + current = value + + pauseTracking() + spawn(listener, value, previous, dispose) + resumeTracking() end - - local previous = current - current = value - - pauseTracking() - spawn(listener, value, previous, disposeSafe) - resumeTracking() end) - if disposed then + if disposeImmediately then dispose() end - return disposeSafe + return dispose end return listen diff --git a/packages/charm/src/mapped.luau b/pkgs/charm/src/mapped.luau similarity index 96% rename from packages/charm/src/mapped.luau rename to pkgs/charm/src/mapped.luau index a842b95..f0bfc05 100644 --- a/packages/charm/src/mapped.luau +++ b/pkgs/charm/src/mapped.luau @@ -19,7 +19,7 @@ local function mapped(selector: () -> { [KI]: VI }, transform: ( resumeTracking() if not success then - error(`observe() transform function failed: {valueOut}`) + error(`mapped() transform function failed: {valueOut}`) end return valueOut, keyOut @@ -47,6 +47,10 @@ local function mapped(selector: () -> { [KI]: VI }, transform: ( return mappedItems end + if previousItems == currentItems then + return previousMappedItems + end + local mappedItems = table.clone(previousMappedItems) -- Update and remove mapped keys if the source values changed diff --git a/pkgs/charm/src/observe.luau b/pkgs/charm/src/observe.luau new file mode 100644 index 0000000..4f07020 --- /dev/null +++ b/pkgs/charm/src/observe.luau @@ -0,0 +1,60 @@ +local signals = require("./signals") + +local computed = signals.computed +local effect = signals.effect +local getCurrentSub = signals.getCurrentSub +local onEffectCleanup = signals.onEffectCleanup +local onScopeDispose = signals.onScopeDispose + +local function noop() end + +local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> () + local tracked = computed(function() + return selector() + end) + + local cleanups: { [K]: () -> () } = {} + local currentItems: { [K]: V } = {} + + local function update(currentItems: { [K]: V }, previousItems: { [K]: V }) + for key in previousItems do + local cleanup = cleanups[key] + if currentItems[key] == nil and cleanup then + cleanups[key] = nil + cleanup() + end + end + + for key, value in currentItems do + if previousItems[key] == nil then + cleanups[key] = observer(value, key) or noop + end + end + end + + local function teardown() + for _, cleanup in cleanups do + cleanup() + end + table.clear(cleanups) + end + + local disposeEffect = effect(function() + local previousItems = currentItems + currentItems = tracked() + update(currentItems, previousItems) + end) + + if getCurrentSub() then + onEffectCleanup(teardown) + else + onScopeDispose(teardown) + end + + return function() + disposeEffect() + teardown() + end +end + +return observe diff --git a/packages/charm/src/signals.luau b/pkgs/charm/src/signals.luau similarity index 75% rename from packages/charm/src/signals.luau rename to pkgs/charm/src/signals.luau index 0fad3e2..a265fee 100644 --- a/packages/charm/src/signals.luau +++ b/pkgs/charm/src/signals.luau @@ -1,5 +1,5 @@ --[=[ - alien-signals 2.0.1 implementation with effect cleanups, adopted from + alien-signals 2.0.1 implementation adopted from https://github.com/Nicell/alien-signals-luau https://github.com/stackblitz/alien-signals @@ -47,7 +47,7 @@ type EffectScope = ReactiveNode & { } type Effect = ReactiveNode & { - fn: () -> (() -> ())?, + fn: () -> (), cleanup: (() -> ())?, } @@ -100,26 +100,36 @@ local function setCurrentScope(scope: EffectScope?) return prevScope end -local function pauseTracking() - pauseStackCount += 1 - pauseStack[pauseStackCount] = activeSub - activeSub = nil +local function addCleanup(e: EffectScope, cleanup: () -> ()) + local prevCleanup = e.cleanup + if prevCleanup then + e.cleanup = function() + prevCleanup() + cleanup() + end + else + e.cleanup = cleanup + end end -local function resumeTracking() - activeSub = pauseStack[pauseStackCount] - pauseStack[pauseStackCount] = nil - pauseStackCount -= 1 +local function onEffectCleanup(cleanup: () -> ()) + if activeSub then + addCleanup(activeSub :: Effect, cleanup) + end end -local function cleanup(e: ReactiveNode) - local e = e :: EffectScope - local cleanup = e.cleanup - if cleanup then - e.cleanup = nil +local function onScopeDispose(cleanup: () -> ()) + if activeScope then + addCleanup(activeScope, cleanup) + end +end + +local function cleanupEffect(e: EffectScope) + if e.cleanup then local prev = setCurrentSub(nil) - local success, result: unknown = pcall(cleanup) + local success, result: unknown = pcall(e.cleanup) setCurrentSub(prev) + e.cleanup = nil if not success then error(result) end @@ -128,16 +138,15 @@ end local function run(e: EffectScope, flags: ReactiveFlags): () if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(e.deps :: Link, e)) then - cleanup(e) + cleanupEffect(e) local prev = setCurrentSub(e) startTracking(e) - local success, result = pcall((e :: Effect).fn) + local success, result: unknown = pcall((e :: Effect).fn) setCurrentSub(prev) endTracking(e) if not success then error(result) end - e.cleanup = result return elseif bit32.band(flags, PENDING) ~= 0 then e.flags = bit32.band(flags, bit32.bnot(PENDING)) @@ -182,6 +191,18 @@ local function endBatch() end end +local function pauseTracking() + pauseStackCount += 1 + pauseStack[pauseStackCount] = activeSub + activeSub = nil +end + +local function resumeTracking() + activeSub = pauseStack[pauseStackCount] + pauseStack[pauseStackCount] = nil + pauseStackCount -= 1 +end + local function updateComputed(c: Computed): boolean local prevSub = setCurrentSub(c) startTracking(c) @@ -210,22 +231,8 @@ local function updateSignal(s: Signal, value: any): boolean end end -local function signalOper(this: Signal, ...: T): T? - if select("#", ...) > 0 then - local newValue = (...) - if this.value ~= newValue then - this.value = newValue - this.flags = bit32.bor(MUTABLE, DIRTY) - local subs = this.subs - if subs then - propagate(subs) - if batchDepth == 0 then - flush() - end - end - end - return - else +local function signalGetterSetter(this: Signal): (() -> T, (value: ((T) -> T) | T) -> T) + local function getter(): T local value = this.value if bit32.band(this.flags, DIRTY) ~= 0 then if updateSignal(this, value) then @@ -240,64 +247,93 @@ local function signalOper(this: Signal, ...: T): T? end return value end -end -local function computedOper(this: Computed): T - local flags = this.flags - if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(this.deps :: Link, this)) then - if updateComputed(this) then + local function setter(newValue: ((T) -> T) | T): T + local value = this.value + local newValue = newValue :: T + if type(newValue) == "function" then + local prev = setCurrentSub(nil) + local success, result = pcall(newValue, value) + setCurrentSub(prev) + if not success then + error(result) + end + newValue = result + end + if value ~= newValue then + this.value = newValue + this.flags = bit32.bor(MUTABLE, DIRTY) local subs = this.subs if subs then - shallowPropagate(subs) + propagate(subs) + if batchDepth == 0 then + flush() + end end end - elseif bit32.band(flags, PENDING) ~= 0 then - this.flags = bit32.band(flags, bit32.bnot(PENDING)) + return newValue end - if activeSub then - link(this, activeSub) - elseif activeScope then - link(this, activeScope) - end - return this.value :: T + + return getter, setter end -local function effectOper(this: EffectScope): () - local dep = this.deps - while dep do - dep = unlink(dep, this) +local function computedGetter(this: Computed): () -> T + return function() + local flags = this.flags + if + bit32.band(flags, DIRTY) ~= 0 + or (bit32.band(flags, PENDING) ~= 0 and checkDirty(this.deps :: Link, this)) + then + if updateComputed(this) then + local subs = this.subs + if subs then + shallowPropagate(subs) + end + end + elseif bit32.band(flags, PENDING) ~= 0 then + this.flags = bit32.band(flags, bit32.bnot(PENDING)) + end + if activeSub then + link(this, activeSub) + elseif activeScope then + link(this, activeScope) + end + return this.value :: T end - local sub = this.subs - while sub do - unlink(sub) - sub = this.subs - end - this.flags = NONE - cleanup(this) end -local signalMetatable = { __call = signalOper } -local computedMetatable = { __call = computedOper } -local effectMetatable = { __call = effectOper } +local function effectStop(this: EffectScope): () -> () + return function() + local dep = this.deps + while dep do + dep = unlink(dep, this) + end + local sub = this.subs + while sub do + unlink(sub) + sub = this.subs + end + this.flags = NONE + cleanupEffect(this) + end +end -local function signal(initialValue: T): ((T) -> ()) & (() -> T) - local this: Signal = { +local function signal(initialValue: T): (() -> T, (value: ((T) -> T) | T) -> T) + return signalGetterSetter({ previousValue = initialValue, value = initialValue, flags = MUTABLE, - } - return setmetatable(this, signalMetatable) :: any + }) end local function computed(getter: (previousValue: T?) -> T): () -> T - local this: Computed = { + return computedGetter({ flags = bit32.bor(MUTABLE, DIRTY), getter = getter, - } - return setmetatable(this, computedMetatable) :: any + }) end -local function effect(fn: () -> ...() -> ()?): () -> () +local function effect(fn: () -> ()): () -> () local e: Effect = { fn = fn, flags = WATCHING, @@ -308,16 +344,15 @@ local function effect(fn: () -> ...() -> ()?): () -> () link(e, activeScope) end local prev = setCurrentSub(e) - local success, result = pcall(fn) + local success, result: unknown = pcall(fn) setCurrentSub(prev) if not success then error(result) end - e.cleanup = result - return setmetatable(e, effectMetatable) :: any + return effectStop(e) end -local function effectScope(fn: () -> ...() -> ()?): () -> () +local function effectScope(fn: () -> ()): () -> () local e: EffectScope = { flags = NONE, } @@ -325,13 +360,12 @@ local function effectScope(fn: () -> ...() -> ()?): () -> () link(e, activeScope) end local prev = setCurrentScope(e) - local success, result = pcall(fn) + local success, result: unknown = pcall(fn) setCurrentScope(prev) if not success then error(result) end - e.cleanup = result - return setmetatable(e, effectMetatable) :: any + return effectStop(e) end local function notify(e: ReactiveNode) @@ -343,7 +377,7 @@ local function notify(e: ReactiveNode) notify(subs.sub :: Effect | EffectScope) else queuedEffectsLength += 1 - queuedEffects[queuedEffectsLength] = e :: Effect | EffectScope + queuedEffects[queuedEffectsLength] = e :: Effect end end end @@ -364,7 +398,7 @@ local function unwatched(signal: ReactiveNode) until not toRemove signal.flags = bit32.bor(signal.flags, DIRTY) end - cleanup(signal) + cleanupEffect(signal :: EffectScope) end local reactiveSystem = createReactiveSystem({ @@ -393,6 +427,8 @@ return { endBatch = endBatch, pauseTracking = pauseTracking, resumeTracking = resumeTracking, + onEffectCleanup = onEffectCleanup, + onScopeDispose = onScopeDispose, signal = signal, computed = computed, effect = effect, diff --git a/packages/charm/src/spawn.luau b/pkgs/charm/src/spawn.luau similarity index 100% rename from packages/charm/src/spawn.luau rename to pkgs/charm/src/spawn.luau diff --git a/packages/charm/src/subscribe.luau b/pkgs/charm/src/subscribe.luau similarity index 86% rename from packages/charm/src/subscribe.luau rename to pkgs/charm/src/subscribe.luau index 745b03a..59ecfff 100644 --- a/packages/charm/src/subscribe.luau +++ b/pkgs/charm/src/subscribe.luau @@ -8,9 +8,8 @@ local resumeTracking = signals.resumeTracking local function subscribe(selector: () -> T, listener: (T, T) -> ()): () -> () local current: T local mounted = true - local dispose: () -> () - dispose = effect(function() + return effect(function() local value = selector() if mounted then @@ -25,10 +24,6 @@ local function subscribe(selector: () -> T, listener: (T, T) -> ()): () -> () resumeTracking() end end) - - return function() - dispose() - end end return subscribe diff --git a/packages/charm/src/system.luau b/pkgs/charm/src/system.luau similarity index 100% rename from packages/charm/src/system.luau rename to pkgs/charm/src/system.luau diff --git a/packages/charm/src/types.luau b/pkgs/charm/src/types.luau similarity index 100% rename from packages/charm/src/types.luau rename to pkgs/charm/src/types.luau diff --git a/packages/charm/src/untracked.luau b/pkgs/charm/src/untracked.luau similarity index 100% rename from packages/charm/src/untracked.luau rename to pkgs/charm/src/untracked.luau diff --git a/packages/charm/wally.toml b/pkgs/charm/wally.toml similarity index 100% rename from packages/charm/wally.toml rename to pkgs/charm/wally.toml diff --git a/tests/utils/renderHook.luau b/pkgs/react-charm/__tests__/helpers/renderHook.luau similarity index 100% rename from tests/utils/renderHook.luau rename to pkgs/react-charm/__tests__/helpers/renderHook.luau diff --git a/pkgs/react-charm/__tests__/spec.luau b/pkgs/react-charm/__tests__/spec.luau new file mode 100644 index 0000000..6cd8895 --- /dev/null +++ b/pkgs/react-charm/__tests__/spec.luau @@ -0,0 +1,15 @@ +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 + end) +end + +spec("useAtom") + +return {} diff --git a/tests/react-charm/useAtom.spec.luau b/pkgs/react-charm/__tests__/useAtom.spec.luau similarity index 94% rename from tests/react-charm/useAtom.spec.luau rename to pkgs/react-charm/__tests__/useAtom.spec.luau index 4e01cd0..340177e 100644 --- a/tests/react-charm/useAtom.spec.luau +++ b/pkgs/react-charm/__tests__/useAtom.spec.luau @@ -3,7 +3,7 @@ local ServerScriptService = game:GetService("ServerScriptService") local Charm = require(ReplicatedStorage.Packages.Charm) local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm) -local renderHook = require(ServerScriptService.Tests.utils.renderHook) +local renderHook = require(script.Parent.helpers.renderHook) local suite = require(ServerScriptService.Tests.suite) local atom = Charm.atom diff --git a/packages/react-charm/default.project.json b/pkgs/react-charm/default.project.json similarity index 100% rename from packages/react-charm/default.project.json rename to pkgs/react-charm/default.project.json diff --git a/packages/react-charm/src/init.luau b/pkgs/react-charm/src/init.luau similarity index 100% rename from packages/react-charm/src/init.luau rename to pkgs/react-charm/src/init.luau diff --git a/packages/react-charm/wally.toml b/pkgs/react-charm/wally.toml similarity index 100% rename from packages/react-charm/wally.toml rename to pkgs/react-charm/wally.toml diff --git a/pkgs/vide-charm/__tests__/spec.luau b/pkgs/vide-charm/__tests__/spec.luau new file mode 100644 index 0000000..6cd8895 --- /dev/null +++ b/pkgs/vide-charm/__tests__/spec.luau @@ -0,0 +1,15 @@ +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 + end) +end + +spec("useAtom") + +return {} diff --git a/tests/vide-charm/useAtom.spec.luau b/pkgs/vide-charm/__tests__/useAtom.spec.luau similarity index 100% rename from tests/vide-charm/useAtom.spec.luau rename to pkgs/vide-charm/__tests__/useAtom.spec.luau diff --git a/packages/vide-charm/default.project.json b/pkgs/vide-charm/default.project.json similarity index 100% rename from packages/vide-charm/default.project.json rename to pkgs/vide-charm/default.project.json diff --git a/packages/vide-charm/src/init.luau b/pkgs/vide-charm/src/init.luau similarity index 100% rename from packages/vide-charm/src/init.luau rename to pkgs/vide-charm/src/init.luau diff --git a/packages/vide-charm/wally.toml b/pkgs/vide-charm/wally.toml similarity index 100% rename from packages/vide-charm/wally.toml rename to pkgs/vide-charm/wally.toml diff --git a/tests/benchmarks/propagate.bench.luau b/tests/benchmarks/propagate.bench.luau deleted file mode 100644 index b81dec7..0000000 --- a/tests/benchmarks/propagate.bench.luau +++ /dev/null @@ -1,39 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Charm = require(ReplicatedStorage.Packages.Charm) - -local signals = Charm.signals - -local function propagate( - signal: (T) -> (T?) -> T, - computed: (() -> T) -> () -> T, - effect: (() -> ()) -> (), - w: number, - h: number -) - local src = signal(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 - -return { - Functions = { - charm = function() - propagate(Charm.atom, Charm.computed, Charm.effect, 10, 10) - end, - alien_signals = function() - propagate(signals.signal, signals.computed, signals.effect, 10, 10) - end, - }, -} diff --git a/tests/benchmarks/propagate.luau b/tests/benchmarks/propagate.luau deleted file mode 100644 index ee56eeb..0000000 --- a/tests/benchmarks/propagate.luau +++ /dev/null @@ -1,59 +0,0 @@ -local Charm = require("../charm") - -local atom = Charm.atom -local computed = Charm.computed -local effect = Charm.effect - -local signal = Charm.signals.signal -local computedSignal = Charm.signals.computed -local effectSignal = Charm.signals.effect - -local w = 10 -local h = 10 - -local function bench(name: string, test: () -> ()) - local start = os.clock() - local iter = 0 - for _ = 1, 15000 do - iter += 1 - test() - end - local endTime = os.clock() - local elapsed = endTime - start - print(`{name}: {elapsed / iter * 1000 * 1000} us/iter`) -end - -bench(`charm {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) - -bench(`alien-signals {w} * {h}`, function() - local src = signal(1) - for _ = 1, w do - local last = src - for _ = 1, h do - local prev = last - last = computedSignal(function() - return prev() + 1 - end) - end - effectSignal(function() - last() - return nil - end) - end - src(src() + 1) -end) diff --git a/tests/charm-sync/spec.luau b/tests/charm-sync/spec.luau deleted file mode 100644 index b53aefc..0000000 --- a/tests/charm-sync/spec.luau +++ /dev/null @@ -1,17 +0,0 @@ -local suite = require("../suite") - -local describe = suite.describe - -local function test(name: string) - describe(name, function() - return require(`./{name}.spec`) :: any - end) -end - -test("client") -test("server") -test("flatten") -test("patch") -test("validate") - -return {} diff --git a/tests/charm.luau b/tests/charm.luau deleted file mode 100644 index aedbca8..0000000 --- a/tests/charm.luau +++ /dev/null @@ -1,3 +0,0 @@ -return if game - then require(game:GetService("ReplicatedStorage").Packages.Charm) - else require("../packages/charm/src/lib") diff --git a/tests/init.luau b/tests/init.luau new file mode 100644 index 0000000..c9f0300 --- /dev/null +++ b/tests/init.luau @@ -0,0 +1,14 @@ +local suite = require("@tests/suite") + +local describe = suite.describe +local finish = suite.finish + +if not table.isfrozen(_G) then + _G.__DEV__ = true +end + +describe("charm", function() + require("@pkgs/charm/__tests__/spec") +end) + +finish() diff --git a/tests/react-charm/spec.luau b/tests/react-charm/spec.luau deleted file mode 100644 index 7fcdaf0..0000000 --- a/tests/react-charm/spec.luau +++ /dev/null @@ -1,13 +0,0 @@ -local suite = require("../suite") - -local describe = suite.describe - -local function test(name: string) - describe(name, function() - return require(`./{name}.spec`) :: any - end) -end - -test("useAtom") - -return {} diff --git a/tests/signals/spec.luau b/tests/signals/spec.luau deleted file mode 100644 index 4853275..0000000 --- a/tests/signals/spec.luau +++ /dev/null @@ -1,18 +0,0 @@ -local suite = require("../suite") - -local describe = suite.describe - -local function test(name: string) - describe(name, function() - return require(`./{name}.spec`) :: any - end) -end - -test("computed") -test("effect") -test("effectScope") -test("issue_48") -test("topology") -test("untrack") - -return {} diff --git a/tests/spec.server.luau b/tests/spec.server.luau index efb9162..61b038c 100644 --- a/tests/spec.server.luau +++ b/tests/spec.server.luau @@ -3,19 +3,18 @@ local suite = require("./suite") local describe = suite.describe local finish = suite.finish -local function test(name: string) - describe(name, function() - return require(`./{name}/spec`) :: any - end) -end +_G.__DEV__ = true -test("signals") +describe("charm-sync", function() + require(script.Parent.CharmSync.spec) +end) -if game then - _G.__DEV__ = true - test("charm-sync") - test("react-charm") - test("vide-charm") -end +describe("react-charm", function() + require(script.Parent.ReactCharm.spec) +end) + +describe("vide-charm", function() + require(script.Parent.VideCharm.spec) +end) finish() diff --git a/tests/utils/collect.luau b/tests/utils/collect.luau deleted file mode 100644 index 0f498aa..0000000 --- a/tests/utils/collect.luau +++ /dev/null @@ -1,12 +0,0 @@ ---[=[ - Trigger a garbage collection cycle by allocating a large amount of junk memory - and then waiting for the next frame. -]=] -local function collect() - for _ = 1, 1e4 do - local _ = table.create(1e3) - end - task.wait() -end - -return collect diff --git a/tests/utils/count.luau b/tests/utils/count.luau deleted file mode 100644 index 251776a..0000000 --- a/tests/utils/count.luau +++ /dev/null @@ -1,15 +0,0 @@ ---[=[ - Counts the number of elements in a table. - - @param object The table to count. - @return The number of elements in the table. -]=] -local function count(object: any): number - local count = 0 - for _ in next, object do - count += 1 - end - return count -end - -return count diff --git a/tests/vide-charm/spec.luau b/tests/vide-charm/spec.luau deleted file mode 100644 index 7fcdaf0..0000000 --- a/tests/vide-charm/spec.luau +++ /dev/null @@ -1,13 +0,0 @@ -local suite = require("../suite") - -local describe = suite.describe - -local function test(name: string) - describe(name, function() - return require(`./{name}.spec`) :: any - end) -end - -test("useAtom") - -return {}