From 33ecae2f501aa42fe220c46e63ab3ccf21c08e3b Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Mon, 25 Mar 2024 19:53:28 -0700 Subject: [PATCH] refactor!: design overhaul (#1) * refactor!: use atom functions * docs: reformat api reference * docs: showcase atom functions * docs: remove unused import * refactor: finalize updates * chore: misc edits --- README.md | 42 ++--- package.json | 4 +- src/__tests__/atom.spec.luau | 62 ++----- src/__tests__/benchmarks/atom.bench.luau | 73 ++++---- src/__tests__/capture.spec.luau | 22 +++ src/__tests__/derive.spec.luau | 72 +++----- src/__tests__/effect.spec.luau | 30 ++++ src/__tests__/init.spec.luau | 4 +- .../{mapAtom.spec.luau => map.spec.luau} | 25 +-- src/__tests__/observe.spec.luau | 9 +- src/__tests__/react/helpers/renderHook.luau | 2 +- src/__tests__/react/useSetAtom.spec.luau | 8 +- src/__tests__/subscribe.spec.luau | 55 +----- src/__tests__/sync/server.spec.luau | 13 +- src/__tests__/unwrap.spec.luau | 23 +++ src/atom.luau | 132 +++------------ src/capture.luau | 25 +++ src/derive.luau | 72 ++------ src/effect.luau | 22 +++ src/index.d.ts | 159 ++++++++---------- src/init.luau | 16 +- src/isAtom.luau | 4 +- src/map.luau | 54 ++++++ src/mapAtom.luau | 56 ------ src/modules/React.luau | 9 +- src/observe.luau | 22 ++- src/react/useAtom.luau | 4 +- src/react/useAtomState.luau | 21 +-- src/react/useSetAtom.luau | 10 +- src/store.luau | 23 +++ src/subscribe.luau | 49 ++---- src/sync/client.luau | 16 +- src/sync/init.luau | 3 - src/sync/patch.luau | 29 ++-- src/sync/server.luau | 60 +++---- src/sync/types.luau | 6 - src/types.luau | 16 ++ src/unwrap.luau | 23 +++ src/utils/reference.luau | 31 ---- 39 files changed, 577 insertions(+), 729 deletions(-) create mode 100644 src/__tests__/capture.spec.luau create mode 100644 src/__tests__/effect.spec.luau rename src/__tests__/{mapAtom.spec.luau => map.spec.luau} (75%) create mode 100644 src/__tests__/unwrap.spec.luau create mode 100644 src/capture.luau create mode 100644 src/effect.luau create mode 100644 src/map.luau delete mode 100644 src/mapAtom.luau create mode 100644 src/store.luau delete mode 100644 src/sync/types.luau create mode 100644 src/types.luau create mode 100644 src/unwrap.luau delete mode 100644 src/utils/reference.luau diff --git a/README.md b/README.md index 2f447fc..f4db5e8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-
+
@@ -45,37 +45,23 @@ Add `littensy/charm` to your `wally.toml` file. Charm = "littensy/charm@VERSION" ``` -## 📚 Documentation +## 📚 API Reference -### Core +### Charm #### `atom(state)` -#### `derive(...atoms, combiner)` +#### `effect(callback)` #### `subscribe(atom, listener)` +#### `derive(atom, equals?)` + #### `observe(atom, observer)` -#### `mapAtom(atom, mapper)` +#### `map(atom, mapper)` -### Atom - -#### `Atom(state?)` - -#### `Atom.get(selector?)` - -#### `Atom.set(state)` - -#### `Atom.memo(areEqual)` - -#### `Atom.readonly()` - -#### `Atom.writable(onSet?)` - -#### `Atom.named(label)` - -#### `Atom.unmount()` +#### `unwrap(atom)` ### React @@ -93,7 +79,7 @@ Charm = "littensy/charm@VERSION" #### `sync.server(options)` -#### `Server.onSync(callback)` +#### `Server.connect(callback)` #### `Server.hydrate(player)` @@ -102,10 +88,10 @@ Charm = "littensy/charm@VERSION" ### Counter atom ```ts -import { atom, derive, subscribe } from "@rbxts/charm"; +import { atom, subscribe } from "@rbxts/charm"; const counterAtom = atom(0); -const doubleCounterAtom = derive(counterAtom, (counter) => counter * 2); +const doubleCounterAtom = () => counterAtom() * 2; subscribe(doubleCounterAtom, (value) => { print(value); @@ -137,12 +123,6 @@ function Counter() { } ``` -## 🙋 Concerns - -In Jotai, `atom()` returns an [atom config](https://jotai.org/docs/core/atom), and its state is managed entirely by the Provider. However, in Charm, `atom()` returns something closer to a store, and its state is managed by the atom itself. - -Charm does this for simplicity in use cases outside of React, but this might go against Jotai's focus on pure functional programming. More insight is needed to determine if this is a good idea. - ---
diff --git a/package.json b/package.json
index 0936d98..c302271 100644
--- a/package.json
+++ b/package.json
@@ -26,9 +26,7 @@
"homepage": "https://github.com/littensy/charm",
"files": [
"src",
- "default.project.json",
- "images/logo.png",
- "!src/**/*.spec.ts"
+ "!src/**/*.spec.luau"
],
"publishConfig": {
"access": "public"
diff --git a/src/__tests__/atom.spec.luau b/src/__tests__/atom.spec.luau
index c41fa63..09cfbb7 100644
--- a/src/__tests__/atom.spec.luau
+++ b/src/__tests__/atom.spec.luau
@@ -1,4 +1,5 @@
local atom = require(script.Parent.Parent.atom)
+local store = require(script.Parent.Parent.store)
return function()
it("updates state", function()
@@ -14,10 +15,10 @@ return function()
it("calls listeners", function()
local source = atom(1)
local value
- local function listener(state)
- value = state
+ local function listener()
+ value = source()
end
- source.listeners[listener] = true
+ store.listeners[source][listener] = true
source(2)
expect(value).to.equal(2)
end)
@@ -31,9 +32,9 @@ return function()
local function listener()
inner += 1
end
- source.listeners[listener] = true
+ store.listeners[source][listener] = true
end
- source.listeners[listener] = true
+ store.listeners[source][listener] = true
source(2)
expect(outer).to.equal(1)
expect(inner).to.equal(0)
@@ -42,44 +43,17 @@ return function()
expect(inner).to.equal(1)
end)
- it("can be readonly", function()
- local readonly = atom(1):readonly()
-
- expect(function()
- (readonly :: any)(2)
- end).to.throw()
-
- local writable = readonly:writable()
-
- writable(2)
- expect(writable()).to.equal(2)
- end)
-
- it("calls the setter", function()
- local source = atom(1):writable(function(_, value: number)
- return value + 1
- end)
- source(1)
- expect(source()).to.equal(2)
- end)
-
- it("cancels if setter returns nil", function()
- local source = atom(1):writable(function(value: number)
- return if value == 2 then nil else value
- end)
- source(2)
- expect(source()).to.equal(1)
- source(3)
- expect(source()).to.equal(3)
- end)
-
- it("can be memoized", function()
- local source = atom(1):memo(function(prev, next)
- return next < prev
- end)
- source(2)
- expect(source()).to.equal(2)
- source(1)
- expect(source()).to.equal(2)
+ it("takes a custom comparator", function()
+ local max = atom(1, {
+ equals = function(prev, next)
+ return next < prev
+ end,
+ })
+ max(2)
+ expect(max()).to.equal(2)
+ max(1)
+ expect(max()).to.equal(2)
+ max(3)
+ expect(max()).to.equal(3)
end)
end
diff --git a/src/__tests__/benchmarks/atom.bench.luau b/src/__tests__/benchmarks/atom.bench.luau
index 2bca275..4f44024 100644
--- a/src/__tests__/benchmarks/atom.bench.luau
+++ b/src/__tests__/benchmarks/atom.bench.luau
@@ -6,48 +6,41 @@ local Package = game:GetService("ReplicatedStorage").Packages.charm
local atom = require(Package.atom)
local derive = require(Package.derive)
-return {
- ParameterGenerator = function()
- return atom({ count = 0 })
+local function parameterGenerator()
+ return atom({ count = 0 })
+end
+
+local benchmarks = {
+ atom = function(_, source: any)
+ for _ = 1, 500 do
+ source({ count = math.random(1, 2) })
+ end
end,
- Functions = {
- atom = function(_, source: any)
- for _ = 1, 500 do
- source({ count = math.random(1, 2) })
- end
- end,
+ derived = function(_, source: any)
+ derive(function()
+ return { count = 2 * source().count }
+ end)
+ for _ = 1, 500 do
+ source({ count = math.random(1, 2) })
+ end
+ end,
- derived = function(_, source: any)
- derive(source, function(state)
- return { count = 2 * state.count }
- end)
- for _ = 1, 500 do
- source({ count = math.random(1, 2) })
- end
- end,
-
- derived_memo = function(_, source: any)
- source:memo(function(previous, current)
+ derived_memo = function(_, source: any)
+ derive(function()
+ return { count = 2 * source().count }
+ end, {
+ equals = function(previous, current)
return previous.count == current.count
- end)
- derive(source, function(state)
- return { count = 2 * state.count }
- end)
- for _ = 1, 500 do
- source({ count = math.random(1, 2) })
- end
- end,
-
- derived_writable = function(_, source: any)
- local derived = derive(source, function(state)
- return { count = 2 * state.count }
- end):writable(function(update)
- source({ count = 0.5 * update.count })
- end)
- for _ = 1, 500 do
- derived({ count = 2 * math.random(1, 2) })
- end
- end,
- },
+ end,
+ })
+ for _ = 1, 500 do
+ source({ count = math.random(1, 2) })
+ end
+ end,
+}
+
+return {
+ ParameterGenerator = parameterGenerator,
+ Functions = benchmarks,
}
diff --git a/src/__tests__/capture.spec.luau b/src/__tests__/capture.spec.luau
new file mode 100644
index 0000000..ae33d99
--- /dev/null
+++ b/src/__tests__/capture.spec.luau
@@ -0,0 +1,22 @@
+local atom = require(script.Parent.Parent.atom)
+local capture = require(script.Parent.Parent.capture)
+
+return function()
+ it("captures atoms from a function", function()
+ local a, b, c = atom(1), atom(1), atom(2)
+ local captured, result = capture(function()
+ return a() + b()
+ end)
+ expect(captured[a]).to.be.ok()
+ expect(captured[b]).to.be.ok()
+ expect(captured[c]).to.never.be.ok()
+ expect(result).to.equal(2)
+ end)
+
+ it("captures an atom by itself", function()
+ local a = atom(1)
+ local captured, result = capture(a)
+ expect(captured[a]).to.be.ok()
+ expect(result).to.equal(1)
+ end)
+end
diff --git a/src/__tests__/derive.spec.luau b/src/__tests__/derive.spec.luau
index 00b60a2..3021a76 100644
--- a/src/__tests__/derive.spec.luau
+++ b/src/__tests__/derive.spec.luau
@@ -1,64 +1,40 @@
local atom = require(script.Parent.Parent.atom)
local derive = require(script.Parent.Parent.derive)
+local store = require(script.Parent.Parent.store)
return function()
- it("derives from dependencies", function()
- local a = atom(1)
- local b = atom(2)
- local sum = derive(a, b, function(a: number, b: number)
- return a + b
- end)
- expect(sum()).to.equal(3)
- a(2)
- expect(sum()).to.equal(4)
- b(3)
- expect(sum()).to.equal(5)
- expect(function()
- (sum :: any)(2)
- end).to.throw()
- end)
-
it("memoizes the result", function()
local calls = 0
- local source = atom(1)
- local double = derive(source, function(value: number)
+ local a = atom(1)
+ local b = atom(2)
+ local double = derive(function()
calls += 1
- return value * 2
+ return a() * b()
end)
expect(calls).to.equal(1)
- source(1)
+ a(1)
expect(calls).to.equal(1)
- source(2)
+ a(2)
expect(calls).to.equal(2)
+ b(3)
+ expect(calls).to.equal(3)
double()
- expect(calls).to.equal(2)
+ expect(calls).to.equal(3)
end)
- it("can be a dependency", function()
+ it("can be nested", function()
local source = atom(1)
- local double = derive(source, function(value: number)
- return value * 2
+ local double = derive(function()
+ return source() * 2
end)
- local quadruple = derive(double, function(value: number)
- return value * 2
+ local quadruple = derive(function()
+ return double() * 2
end)
expect(quadruple()).to.equal(4)
source(2)
expect(quadruple()).to.equal(8)
end)
- it("allows custom setter", function()
- local source = atom(1)
- local double = derive(source, function(value: number)
- return value * 2
- end):writable(function(update: number)
- source(update / 2)
- end)
- double(4)
- expect(source()).to.equal(2)
- expect(double()).to.equal(4)
- end)
-
it("garbage collects", function()
-- large values coerce the garbage collector to run
local source = atom(string.rep("x", 1e5))
@@ -66,7 +42,7 @@ return function()
local function listenersDecreased()
local count = 0
- for _ in source.listeners do
+ for _ in pairs(store.listeners[source]) do
count += 1
end
max = math.max(max, count)
@@ -74,11 +50,11 @@ return function()
end
for index = 1, 500 do
- derive(source, function(value)
- return string.upper(value)
+ derive(function()
+ return string.upper(source())
end)
if index % 50 == 0 then
- -- derived atoms check if they are collected when they are updated
+ -- derived atoms should be collected when memory is stressed
source(source() .. "x")
task.wait()
end
@@ -89,14 +65,4 @@ return function()
error("None of the derived atoms were collected")
end)
-
- it("unmounts from parent atom", function()
- local source = atom(1)
- local derived = derive(source, function(value)
- return value
- end)
- expect((next(source.listeners))).to.be.ok()
- derived:unmount()
- expect((next(source.listeners))).to.never.be.ok()
- end)
end
diff --git a/src/__tests__/effect.spec.luau b/src/__tests__/effect.spec.luau
new file mode 100644
index 0000000..d1b0337
--- /dev/null
+++ b/src/__tests__/effect.spec.luau
@@ -0,0 +1,30 @@
+local atom = require(script.Parent.Parent.atom)
+local effect = require(script.Parent.Parent.effect)
+
+return function()
+ it("runs on state change", function()
+ local a = atom(0)
+ local b = atom(0)
+ local current
+ local unsubscribe = effect(function()
+ current = a() + b()
+ end)
+
+ expect(current).to.equal(0)
+
+ for index = 1, 3 do
+ a(index)
+ expect(current).to.equal(index)
+ end
+
+ for index = 1, 3 do
+ b(index)
+ expect(current).to.equal(a() + index)
+ end
+
+ unsubscribe()
+ a(4)
+
+ expect(current).to.equal(6)
+ end)
+end
diff --git a/src/__tests__/init.spec.luau b/src/__tests__/init.spec.luau
index c37cf45..a7878dc 100644
--- a/src/__tests__/init.spec.luau
+++ b/src/__tests__/init.spec.luau
@@ -1,4 +1,4 @@
-local reference = require(script.Parent.Parent.utils.reference)
+local store = require(script.Parent.Parent.store)
return function()
local previous = _G.__DEV__
@@ -12,6 +12,6 @@ return function()
end)
afterEach(function()
- reference.clear()
+ table.clear(store.listeners :: any)
end)
end
diff --git a/src/__tests__/mapAtom.spec.luau b/src/__tests__/map.spec.luau
similarity index 75%
rename from src/__tests__/mapAtom.spec.luau
rename to src/__tests__/map.spec.luau
index 05ec132..79b5916 100644
--- a/src/__tests__/mapAtom.spec.luau
+++ b/src/__tests__/map.spec.luau
@@ -1,16 +1,17 @@
local atom = require(script.Parent.Parent.atom)
-local mapAtom = require(script.Parent.Parent.mapAtom)
+local map = require(script.Parent.Parent.map)
local observe = require(script.Parent.Parent.observe)
+local store = require(script.Parent.Parent.store)
return function()
it("returns mapped atoms", function()
local itemsAtom = atom({ "a", "b", "c" })
- local mappedAtom = mapAtom(itemsAtom, function(value, key)
+ local mappedAtom = map(itemsAtom, function(value, key)
return string.upper(value), key
end)
local updates = 0
- mappedAtom.listeners[function()
+ store.listeners[mappedAtom][function()
updates += 1
end] = true
@@ -34,7 +35,7 @@ return function()
it("uses key extractor", function()
local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } })
- local mappedAtom = mapAtom(itemsAtom, function(item)
+ local mappedAtom = map(itemsAtom, function(item)
return item, item.key
end)
@@ -60,14 +61,14 @@ return function()
{ key = 3, value = "c" },
})
- local mappedAtom = mapAtom(itemsAtom, function(item)
+ local mappedAtom = map(itemsAtom, function(item)
return item, item.key
end)
local added = 0
local deleted = 0
- local _, flush = observe(mappedAtom, function(item, key)
+ observe(mappedAtom, function(item, key)
expect(item).to.equal(itemsAtom()[key])
added += 1
@@ -81,25 +82,13 @@ return function()
expect(deleted).to.equal(0)
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
- flush()
expect(added).to.equal(3)
expect(deleted).to.equal(1)
itemsAtom({})
- flush()
expect(added).to.equal(3)
expect(deleted).to.equal(3)
end)
-
- it("unmounts from parent atom", function()
- local itemsAtom = atom({})
- local mappedAtom = mapAtom(itemsAtom, function(item, key)
- return item, key
- end)
- expect((next(itemsAtom.listeners))).to.be.ok()
- mappedAtom:unmount()
- expect((next(itemsAtom.listeners))).to.never.be.ok()
- end)
end
diff --git a/src/__tests__/observe.spec.luau b/src/__tests__/observe.spec.luau
index 79e8953..a4e6b63 100644
--- a/src/__tests__/observe.spec.luau
+++ b/src/__tests__/observe.spec.luau
@@ -6,7 +6,7 @@ return function()
local itemsAtom = atom({ "a", "b", "c" })
local state = {}
- local cleanup, flush = observe(itemsAtom, function(item, key)
+ local cleanup = observe(itemsAtom, function(item, key)
state[key] = item
return function()
state[key] = nil
@@ -14,14 +14,13 @@ return function()
end)
for key = 1, 3 do
- expect(state[key]).to.equal(itemsAtom:get()[key])
+ expect(state[key]).to.equal(itemsAtom()[key])
end
- itemsAtom:set({ [4] = "d", [5] = "e", [6] = "f" })
- flush()
+ itemsAtom({ [4] = "d", [5] = "e", [6] = "f" })
for key = 1, 6 do
- expect(state[key]).to.equal(itemsAtom:get()[key])
+ expect(state[key]).to.equal(itemsAtom()[key])
end
cleanup()
diff --git a/src/__tests__/react/helpers/renderHook.luau b/src/__tests__/react/helpers/renderHook.luau
index 1714a66..020b2ae 100644
--- a/src/__tests__/react/helpers/renderHook.luau
+++ b/src/__tests__/react/helpers/renderHook.luau
@@ -2,7 +2,7 @@ _G.__ROACT_17_MOCK_SCHEDULER__ = true
local Package = script:FindFirstAncestor("__tests__").Parent
-local React = require(Package.modules.React)
+local React = require(Package.modules.React)()
local ReactRoblox = require(Package.modules.ReactRoblox)
type RenderedHook