mirror of
https://github.com/littensy/charm.git
synced 2026-10-22 12:41:37 +00:00
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
This commit is contained in:
parent
4d4979daa9
commit
33ecae2f50
39 changed files with 577 additions and 729 deletions
42
README.md
42
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img width="150" height="150" src="images/logo.png" alt="Logo">
|
<img width="150" height="150" src="https://raw.githubusercontent.com/littensy/charm/master/images/logo.png" alt="Logo">
|
||||||
</p>
|
</p>
|
||||||
<h1 align="center"><b>Charm</b></h1>
|
<h1 align="center"><b>Charm</b></h1>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
@ -45,37 +45,23 @@ Add `littensy/charm` to your `wally.toml` file.
|
||||||
Charm = "littensy/charm@VERSION"
|
Charm = "littensy/charm@VERSION"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📚 Documentation
|
## 📚 API Reference
|
||||||
|
|
||||||
### Core
|
### Charm
|
||||||
|
|
||||||
#### `atom(state)`
|
#### `atom(state)`
|
||||||
|
|
||||||
#### `derive(...atoms, combiner)`
|
#### `effect(callback)`
|
||||||
|
|
||||||
#### `subscribe(atom, listener)`
|
#### `subscribe(atom, listener)`
|
||||||
|
|
||||||
|
#### `derive(atom, equals?)`
|
||||||
|
|
||||||
#### `observe(atom, observer)`
|
#### `observe(atom, observer)`
|
||||||
|
|
||||||
#### `mapAtom(atom, mapper)`
|
#### `map(atom, mapper)`
|
||||||
|
|
||||||
### Atom
|
#### `unwrap(atom)`
|
||||||
|
|
||||||
#### `Atom(state?)`
|
|
||||||
|
|
||||||
#### `Atom.get(selector?)`
|
|
||||||
|
|
||||||
#### `Atom.set(state)`
|
|
||||||
|
|
||||||
#### `Atom.memo(areEqual)`
|
|
||||||
|
|
||||||
#### `Atom.readonly()`
|
|
||||||
|
|
||||||
#### `Atom.writable(onSet?)`
|
|
||||||
|
|
||||||
#### `Atom.named(label)`
|
|
||||||
|
|
||||||
#### `Atom.unmount()`
|
|
||||||
|
|
||||||
### React
|
### React
|
||||||
|
|
||||||
|
|
@ -93,7 +79,7 @@ Charm = "littensy/charm@VERSION"
|
||||||
|
|
||||||
#### `sync.server(options)`
|
#### `sync.server(options)`
|
||||||
|
|
||||||
#### `Server.onSync(callback)`
|
#### `Server.connect(callback)`
|
||||||
|
|
||||||
#### `Server.hydrate(player)`
|
#### `Server.hydrate(player)`
|
||||||
|
|
||||||
|
|
@ -102,10 +88,10 @@ Charm = "littensy/charm@VERSION"
|
||||||
### Counter atom
|
### Counter atom
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { atom, derive, subscribe } from "@rbxts/charm";
|
import { atom, subscribe } from "@rbxts/charm";
|
||||||
|
|
||||||
const counterAtom = atom(0);
|
const counterAtom = atom(0);
|
||||||
const doubleCounterAtom = derive(counterAtom, (counter) => counter * 2);
|
const doubleCounterAtom = () => counterAtom() * 2;
|
||||||
|
|
||||||
subscribe(doubleCounterAtom, (value) => {
|
subscribe(doubleCounterAtom, (value) => {
|
||||||
print(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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,7 @@
|
||||||
"homepage": "https://github.com/littensy/charm",
|
"homepage": "https://github.com/littensy/charm",
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"default.project.json",
|
"!src/**/*.spec.luau"
|
||||||
"images/logo.png",
|
|
||||||
"!src/**/*.spec.ts"
|
|
||||||
],
|
],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local atom = require(script.Parent.Parent.atom)
|
||||||
|
local store = require(script.Parent.Parent.store)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("updates state", function()
|
it("updates state", function()
|
||||||
|
|
@ -14,10 +15,10 @@ return function()
|
||||||
it("calls listeners", function()
|
it("calls listeners", function()
|
||||||
local source = atom(1)
|
local source = atom(1)
|
||||||
local value
|
local value
|
||||||
local function listener(state)
|
local function listener()
|
||||||
value = state
|
value = source()
|
||||||
end
|
end
|
||||||
source.listeners[listener] = true
|
store.listeners[source][listener] = true
|
||||||
source(2)
|
source(2)
|
||||||
expect(value).to.equal(2)
|
expect(value).to.equal(2)
|
||||||
end)
|
end)
|
||||||
|
|
@ -31,9 +32,9 @@ return function()
|
||||||
local function listener()
|
local function listener()
|
||||||
inner += 1
|
inner += 1
|
||||||
end
|
end
|
||||||
source.listeners[listener] = true
|
store.listeners[source][listener] = true
|
||||||
end
|
end
|
||||||
source.listeners[listener] = true
|
store.listeners[source][listener] = true
|
||||||
source(2)
|
source(2)
|
||||||
expect(outer).to.equal(1)
|
expect(outer).to.equal(1)
|
||||||
expect(inner).to.equal(0)
|
expect(inner).to.equal(0)
|
||||||
|
|
@ -42,44 +43,17 @@ return function()
|
||||||
expect(inner).to.equal(1)
|
expect(inner).to.equal(1)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("can be readonly", function()
|
it("takes a custom comparator", function()
|
||||||
local readonly = atom(1):readonly()
|
local max = atom(1, {
|
||||||
|
equals = function(prev, next)
|
||||||
expect(function()
|
return next < prev
|
||||||
(readonly :: any)(2)
|
end,
|
||||||
end).to.throw()
|
})
|
||||||
|
max(2)
|
||||||
local writable = readonly:writable()
|
expect(max()).to.equal(2)
|
||||||
|
max(1)
|
||||||
writable(2)
|
expect(max()).to.equal(2)
|
||||||
expect(writable()).to.equal(2)
|
max(3)
|
||||||
end)
|
expect(max()).to.equal(3)
|
||||||
|
|
||||||
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)
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,48 +6,41 @@ local Package = game:GetService("ReplicatedStorage").Packages.charm
|
||||||
local atom = require(Package.atom)
|
local atom = require(Package.atom)
|
||||||
local derive = require(Package.derive)
|
local derive = require(Package.derive)
|
||||||
|
|
||||||
return {
|
local function parameterGenerator()
|
||||||
ParameterGenerator = function()
|
return atom({ count = 0 })
|
||||||
return atom({ count = 0 })
|
end
|
||||||
|
|
||||||
|
local benchmarks = {
|
||||||
|
atom = function(_, source: any)
|
||||||
|
for _ = 1, 500 do
|
||||||
|
source({ count = math.random(1, 2) })
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
|
|
||||||
Functions = {
|
derived = function(_, source: any)
|
||||||
atom = function(_, source: any)
|
derive(function()
|
||||||
for _ = 1, 500 do
|
return { count = 2 * source().count }
|
||||||
source({ count = math.random(1, 2) })
|
end)
|
||||||
end
|
for _ = 1, 500 do
|
||||||
end,
|
source({ count = math.random(1, 2) })
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
|
||||||
derived = function(_, source: any)
|
derived_memo = function(_, source: any)
|
||||||
derive(source, function(state)
|
derive(function()
|
||||||
return { count = 2 * state.count }
|
return { count = 2 * source().count }
|
||||||
end)
|
end, {
|
||||||
for _ = 1, 500 do
|
equals = function(previous, current)
|
||||||
source({ count = math.random(1, 2) })
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
|
|
||||||
derived_memo = function(_, source: any)
|
|
||||||
source:memo(function(previous, current)
|
|
||||||
return previous.count == current.count
|
return previous.count == current.count
|
||||||
end)
|
end,
|
||||||
derive(source, function(state)
|
})
|
||||||
return { count = 2 * state.count }
|
for _ = 1, 500 do
|
||||||
end)
|
source({ count = math.random(1, 2) })
|
||||||
for _ = 1, 500 do
|
end
|
||||||
source({ count = math.random(1, 2) })
|
end,
|
||||||
end
|
}
|
||||||
end,
|
|
||||||
|
return {
|
||||||
derived_writable = function(_, source: any)
|
ParameterGenerator = parameterGenerator,
|
||||||
local derived = derive(source, function(state)
|
Functions = benchmarks,
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
src/__tests__/capture.spec.luau
Normal file
22
src/__tests__/capture.spec.luau
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,64 +1,40 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local atom = require(script.Parent.Parent.atom)
|
||||||
local derive = require(script.Parent.Parent.derive)
|
local derive = require(script.Parent.Parent.derive)
|
||||||
|
local store = require(script.Parent.Parent.store)
|
||||||
|
|
||||||
return function()
|
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()
|
it("memoizes the result", function()
|
||||||
local calls = 0
|
local calls = 0
|
||||||
local source = atom(1)
|
local a = atom(1)
|
||||||
local double = derive(source, function(value: number)
|
local b = atom(2)
|
||||||
|
local double = derive(function()
|
||||||
calls += 1
|
calls += 1
|
||||||
return value * 2
|
return a() * b()
|
||||||
end)
|
end)
|
||||||
expect(calls).to.equal(1)
|
expect(calls).to.equal(1)
|
||||||
source(1)
|
a(1)
|
||||||
expect(calls).to.equal(1)
|
expect(calls).to.equal(1)
|
||||||
source(2)
|
a(2)
|
||||||
expect(calls).to.equal(2)
|
expect(calls).to.equal(2)
|
||||||
|
b(3)
|
||||||
|
expect(calls).to.equal(3)
|
||||||
double()
|
double()
|
||||||
expect(calls).to.equal(2)
|
expect(calls).to.equal(3)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("can be a dependency", function()
|
it("can be nested", function()
|
||||||
local source = atom(1)
|
local source = atom(1)
|
||||||
local double = derive(source, function(value: number)
|
local double = derive(function()
|
||||||
return value * 2
|
return source() * 2
|
||||||
end)
|
end)
|
||||||
local quadruple = derive(double, function(value: number)
|
local quadruple = derive(function()
|
||||||
return value * 2
|
return double() * 2
|
||||||
end)
|
end)
|
||||||
expect(quadruple()).to.equal(4)
|
expect(quadruple()).to.equal(4)
|
||||||
source(2)
|
source(2)
|
||||||
expect(quadruple()).to.equal(8)
|
expect(quadruple()).to.equal(8)
|
||||||
end)
|
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()
|
it("garbage collects", function()
|
||||||
-- large values coerce the garbage collector to run
|
-- large values coerce the garbage collector to run
|
||||||
local source = atom(string.rep("x", 1e5))
|
local source = atom(string.rep("x", 1e5))
|
||||||
|
|
@ -66,7 +42,7 @@ return function()
|
||||||
|
|
||||||
local function listenersDecreased()
|
local function listenersDecreased()
|
||||||
local count = 0
|
local count = 0
|
||||||
for _ in source.listeners do
|
for _ in pairs(store.listeners[source]) do
|
||||||
count += 1
|
count += 1
|
||||||
end
|
end
|
||||||
max = math.max(max, count)
|
max = math.max(max, count)
|
||||||
|
|
@ -74,11 +50,11 @@ return function()
|
||||||
end
|
end
|
||||||
|
|
||||||
for index = 1, 500 do
|
for index = 1, 500 do
|
||||||
derive(source, function(value)
|
derive(function()
|
||||||
return string.upper(value)
|
return string.upper(source())
|
||||||
end)
|
end)
|
||||||
if index % 50 == 0 then
|
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")
|
source(source() .. "x")
|
||||||
task.wait()
|
task.wait()
|
||||||
end
|
end
|
||||||
|
|
@ -89,14 +65,4 @@ return function()
|
||||||
|
|
||||||
error("None of the derived atoms were collected")
|
error("None of the derived atoms were collected")
|
||||||
end)
|
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
|
end
|
||||||
|
|
|
||||||
30
src/__tests__/effect.spec.luau
Normal file
30
src/__tests__/effect.spec.luau
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
local reference = require(script.Parent.Parent.utils.reference)
|
local store = require(script.Parent.Parent.store)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
local previous = _G.__DEV__
|
local previous = _G.__DEV__
|
||||||
|
|
@ -12,6 +12,6 @@ return function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
afterEach(function()
|
afterEach(function()
|
||||||
reference.clear()
|
table.clear(store.listeners :: any)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
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 observe = require(script.Parent.Parent.observe)
|
||||||
|
local store = require(script.Parent.Parent.store)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("returns mapped atoms", function()
|
it("returns mapped atoms", function()
|
||||||
local itemsAtom = atom({ "a", "b", "c" })
|
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
|
return string.upper(value), key
|
||||||
end)
|
end)
|
||||||
local updates = 0
|
local updates = 0
|
||||||
|
|
||||||
mappedAtom.listeners[function()
|
store.listeners[mappedAtom][function()
|
||||||
updates += 1
|
updates += 1
|
||||||
end] = true
|
end] = true
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ return function()
|
||||||
|
|
||||||
it("uses key extractor", function()
|
it("uses key extractor", function()
|
||||||
local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } })
|
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
|
return item, item.key
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -60,14 +61,14 @@ return function()
|
||||||
{ key = 3, value = "c" },
|
{ key = 3, value = "c" },
|
||||||
})
|
})
|
||||||
|
|
||||||
local mappedAtom = mapAtom(itemsAtom, function(item)
|
local mappedAtom = map(itemsAtom, function(item)
|
||||||
return item, item.key
|
return item, item.key
|
||||||
end)
|
end)
|
||||||
|
|
||||||
local added = 0
|
local added = 0
|
||||||
local deleted = 0
|
local deleted = 0
|
||||||
|
|
||||||
local _, flush = observe(mappedAtom, function(item, key)
|
observe(mappedAtom, function(item, key)
|
||||||
expect(item).to.equal(itemsAtom()[key])
|
expect(item).to.equal(itemsAtom()[key])
|
||||||
added += 1
|
added += 1
|
||||||
|
|
||||||
|
|
@ -81,25 +82,13 @@ return function()
|
||||||
expect(deleted).to.equal(0)
|
expect(deleted).to.equal(0)
|
||||||
|
|
||||||
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
|
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
|
||||||
flush()
|
|
||||||
|
|
||||||
expect(added).to.equal(3)
|
expect(added).to.equal(3)
|
||||||
expect(deleted).to.equal(1)
|
expect(deleted).to.equal(1)
|
||||||
|
|
||||||
itemsAtom({})
|
itemsAtom({})
|
||||||
flush()
|
|
||||||
|
|
||||||
expect(added).to.equal(3)
|
expect(added).to.equal(3)
|
||||||
expect(deleted).to.equal(3)
|
expect(deleted).to.equal(3)
|
||||||
end)
|
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
|
end
|
||||||
|
|
@ -6,7 +6,7 @@ return function()
|
||||||
local itemsAtom = atom({ "a", "b", "c" })
|
local itemsAtom = atom({ "a", "b", "c" })
|
||||||
local state = {}
|
local state = {}
|
||||||
|
|
||||||
local cleanup, flush = observe(itemsAtom, function(item, key)
|
local cleanup = observe(itemsAtom, function(item, key)
|
||||||
state[key] = item
|
state[key] = item
|
||||||
return function()
|
return function()
|
||||||
state[key] = nil
|
state[key] = nil
|
||||||
|
|
@ -14,14 +14,13 @@ return function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
for key = 1, 3 do
|
for key = 1, 3 do
|
||||||
expect(state[key]).to.equal(itemsAtom:get()[key])
|
expect(state[key]).to.equal(itemsAtom()[key])
|
||||||
end
|
end
|
||||||
|
|
||||||
itemsAtom:set({ [4] = "d", [5] = "e", [6] = "f" })
|
itemsAtom({ [4] = "d", [5] = "e", [6] = "f" })
|
||||||
flush()
|
|
||||||
|
|
||||||
for key = 1, 6 do
|
for key = 1, 6 do
|
||||||
expect(state[key]).to.equal(itemsAtom:get()[key])
|
expect(state[key]).to.equal(itemsAtom()[key])
|
||||||
end
|
end
|
||||||
|
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ _G.__ROACT_17_MOCK_SCHEDULER__ = true
|
||||||
|
|
||||||
local Package = script:FindFirstAncestor("__tests__").Parent
|
local Package = script:FindFirstAncestor("__tests__").Parent
|
||||||
|
|
||||||
local React = require(Package.modules.React)
|
local React = require(Package.modules.React)()
|
||||||
local ReactRoblox = require(Package.modules.ReactRoblox)
|
local ReactRoblox = require(Package.modules.ReactRoblox)
|
||||||
|
|
||||||
type RenderedHook<Props, Result> = {
|
type RenderedHook<Props, Result> = {
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,11 @@ return function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("memoizes the function", function()
|
it("memoizes the function", function()
|
||||||
local hook = renderHook(function(source)
|
local hook = renderHook(function(props)
|
||||||
return useSetAtom(source)
|
return useSetAtom(props.source)
|
||||||
end, atom(0))
|
end, { source = atom(0) })
|
||||||
local fn = hook.result
|
local fn = hook.result
|
||||||
hook.rerender(atom(1))
|
hook.rerender({ source = atom(1) })
|
||||||
expect(hook.result).to.never.equal(fn)
|
expect(hook.result).to.never.equal(fn)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,62 +6,23 @@ return function()
|
||||||
it("runs on state change", function()
|
it("runs on state change", function()
|
||||||
local current, previous
|
local current, previous
|
||||||
local source = atom(0)
|
local source = atom(0)
|
||||||
local unsubscribe, flush = subscribe(source, function(...)
|
local unsubscribe = subscribe(source, function(...)
|
||||||
current, previous = ...
|
current, previous = ...
|
||||||
end)
|
end)
|
||||||
|
|
||||||
for index = 1, 3 do
|
for index = 1, 3 do
|
||||||
source(index)
|
source(index)
|
||||||
flush()
|
|
||||||
expect(current).to.equal(index)
|
expect(current).to.equal(index)
|
||||||
expect(previous).to.equal(index - 1)
|
expect(previous).to.equal(index - 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
source(4)
|
source(4)
|
||||||
flush()
|
|
||||||
|
|
||||||
expect(current).to.equal(3)
|
expect(current).to.equal(3)
|
||||||
expect(previous).to.equal(2)
|
expect(previous).to.equal(2)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("batches updates", function()
|
|
||||||
local current, previous
|
|
||||||
local source = atom(0)
|
|
||||||
local _, flush = subscribe(source, function(...)
|
|
||||||
current, previous = ...
|
|
||||||
end)
|
|
||||||
|
|
||||||
source(1)
|
|
||||||
source(2)
|
|
||||||
flush()
|
|
||||||
|
|
||||||
expect(current).to.equal(2)
|
|
||||||
expect(previous).to.equal(0)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it("checks equality", function()
|
|
||||||
local current, previous
|
|
||||||
local source = atom(0):memo(function(previous, current)
|
|
||||||
return current < previous
|
|
||||||
end)
|
|
||||||
local _, flush = subscribe(source, function(...)
|
|
||||||
current, previous = ...
|
|
||||||
end)
|
|
||||||
|
|
||||||
source(1)
|
|
||||||
flush()
|
|
||||||
|
|
||||||
expect(current).to.equal(1)
|
|
||||||
expect(previous).to.equal(0)
|
|
||||||
|
|
||||||
source(0)
|
|
||||||
flush()
|
|
||||||
|
|
||||||
expect(current).to.equal(1)
|
|
||||||
expect(previous).to.equal(0)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it("prevents garbage collect", function()
|
it("prevents garbage collect", function()
|
||||||
local source = atom(1)
|
local source = atom(1)
|
||||||
local updates = 0
|
local updates = 0
|
||||||
|
|
@ -70,15 +31,13 @@ return function()
|
||||||
-- The idea here is to test whether subscribe() prevents every derived
|
-- The idea here is to test whether subscribe() prevents every derived
|
||||||
-- atom in a chain from being garbage collected. If any are GCed, then
|
-- atom in a chain from being garbage collected. If any are GCed, then
|
||||||
-- the subscription will stop updating and the test will fail.
|
-- the subscription will stop updating and the test will fail.
|
||||||
|
local derived = derive(function()
|
||||||
|
return source()
|
||||||
|
end)
|
||||||
subscribe(
|
subscribe(
|
||||||
derive(
|
derive(function()
|
||||||
derive(source, function(x)
|
return derived() * 2
|
||||||
return x
|
end),
|
||||||
end),
|
|
||||||
function(x)
|
|
||||||
return 2 * x
|
|
||||||
end
|
|
||||||
),
|
|
||||||
function()
|
function()
|
||||||
updates += 1
|
updates += 1
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
local atom = require(script.Parent.Parent.Parent.atom)
|
local atom = require(script.Parent.Parent.Parent.atom)
|
||||||
local sync = require(script.Parent.Parent.Parent.sync)
|
local sync = require(script.Parent.Parent.Parent.sync)
|
||||||
|
local types = require(script.Parent.Parent.Parent.types)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
local target: Player = {} :: any
|
local target: Player = {} :: any
|
||||||
|
|
@ -7,9 +8,9 @@ return function()
|
||||||
it("sends state to new players", function()
|
it("sends state to new players", function()
|
||||||
local a = atom(1)
|
local a = atom(1)
|
||||||
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
||||||
local player: Player, payload: sync.SyncPayload
|
local player: Player, payload: types.SyncPayload
|
||||||
|
|
||||||
server:onSync(function(...)
|
local disconnect = server:connect(function(...)
|
||||||
player, payload = ...
|
player, payload = ...
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -18,14 +19,16 @@ return function()
|
||||||
expect(player).to.equal(target)
|
expect(player).to.equal(target)
|
||||||
expect(payload.type).to.equal("set")
|
expect(payload.type).to.equal("set")
|
||||||
expect(payload.data.a).to.equal(1)
|
expect(payload.data.a).to.equal(1)
|
||||||
|
|
||||||
|
disconnect()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("sends state patches", function()
|
it("sends state patches", function()
|
||||||
local a = atom({ b = 1, c = 2 })
|
local a = atom({ b = 1, c = 2 })
|
||||||
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
||||||
local player: Player, payload: sync.SyncPayload
|
local player: Player, payload: types.SyncPayload
|
||||||
|
|
||||||
server:onSync(function(...)
|
local disconnect = server:connect(function(...)
|
||||||
player, payload = ...
|
player, payload = ...
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -36,5 +39,7 @@ return function()
|
||||||
expect(payload.type).to.equal("patch")
|
expect(payload.type).to.equal("patch")
|
||||||
expect(payload.data.a.b).to.never.be.ok()
|
expect(payload.data.a.b).to.never.be.ok()
|
||||||
expect(payload.data.a.c).to.equal(5)
|
expect(payload.data.a.c).to.equal(5)
|
||||||
|
|
||||||
|
disconnect()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
23
src/__tests__/unwrap.spec.luau
Normal file
23
src/__tests__/unwrap.spec.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
local atom = require(script.Parent.Parent.atom)
|
||||||
|
local capture = require(script.Parent.Parent.capture)
|
||||||
|
local unwrap = require(script.Parent.Parent.unwrap)
|
||||||
|
|
||||||
|
return function()
|
||||||
|
it("calls an atom without capturing it", function()
|
||||||
|
local a, b, c = atom(1), atom(1), atom(1)
|
||||||
|
local captured, result = capture(function()
|
||||||
|
return a() + unwrap(b) + c()
|
||||||
|
end)
|
||||||
|
expect(captured[a]).to.be.ok()
|
||||||
|
expect(captured[b]).to.never.be.ok()
|
||||||
|
expect(captured[c]).to.be.ok()
|
||||||
|
expect(result).to.equal(3)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("passes through arguments", function()
|
||||||
|
expect(unwrap(1)).to.equal(1)
|
||||||
|
expect(unwrap(function()
|
||||||
|
return 1
|
||||||
|
end)).to.equal(1)
|
||||||
|
end)
|
||||||
|
end
|
||||||
132
src/atom.luau
132
src/atom.luau
|
|
@ -1,126 +1,34 @@
|
||||||
local reference = require(script.Parent.utils.reference)
|
local store = require(script.Parent.store)
|
||||||
|
local types = require(script.Parent.types)
|
||||||
|
type Atom<T> = types.Atom<T>
|
||||||
|
type AtomOptions<T> = types.AtomOptions<T>
|
||||||
|
|
||||||
type AtomInternal<T> = {
|
local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
|
||||||
setForced: (self: AtomInternal<T>, state: T | ((T) -> T)) -> (),
|
local equals = options and options.equals
|
||||||
listeners: { [(T) -> ()]: true },
|
|
||||||
debugTraceback: string,
|
|
||||||
debugLabel: string,
|
|
||||||
owners: { [any]: true },
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Atom<T> = (() -> T) & ((state: T | ((T) -> T)) -> ()) & {
|
local function atom(...)
|
||||||
get: ((self: Atom<T>) -> T) & (<Result>(self: Atom<T>, selector: (T) -> Result) -> Result),
|
if select("#", ...) == 0 then
|
||||||
set: (self: Atom<T>, state: T | ((T) -> T)) -> (),
|
if store.shouldCapture() then
|
||||||
memo: (self: Atom<T>, areEqual: (previous: T, current: T) -> boolean) -> Atom<T>,
|
store.captured[atom] = true
|
||||||
writable: (self: Atom<T>, onSet: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom<T>,
|
|
||||||
readonly: (self: Atom<T>) -> ReadonlyAtom<T>,
|
|
||||||
named: (self: Atom<T>, label: string) -> Atom<T>,
|
|
||||||
unmount: (self: Atom<T>) -> (),
|
|
||||||
} & AtomInternal<T>
|
|
||||||
|
|
||||||
export type ReadonlyAtom<T> = (() -> T) & {
|
|
||||||
get: ((self: ReadonlyAtom<T>) -> T) & (<Result>(self: ReadonlyAtom<T>, selector: (T) -> Result) -> Result),
|
|
||||||
memo: (self: ReadonlyAtom<T>, areEqual: (previous: T, current: T) -> boolean) -> ReadonlyAtom<T>,
|
|
||||||
writable: ((self: Atom<T>) -> Atom<T>)
|
|
||||||
& ((self: Atom<T>, onSet: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom<T>),
|
|
||||||
readonly: (self: ReadonlyAtom<T>) -> ReadonlyAtom<T>,
|
|
||||||
named: (self: ReadonlyAtom<T>, label: string) -> ReadonlyAtom<T>,
|
|
||||||
unmount: (self: ReadonlyAtom<T>) -> (),
|
|
||||||
} & AtomInternal<T>
|
|
||||||
|
|
||||||
local atomCount = 0
|
|
||||||
|
|
||||||
local function atom<State>(state: State): Atom<State>
|
|
||||||
atomCount += 1
|
|
||||||
|
|
||||||
local atom = {
|
|
||||||
listeners = {},
|
|
||||||
debugTraceback = debug.traceback(nil, 2),
|
|
||||||
debugLabel = `atom{atomCount}`,
|
|
||||||
owners = {},
|
|
||||||
} :: Atom<State>
|
|
||||||
|
|
||||||
local equals: ((State, State) -> boolean)?
|
|
||||||
local setter: ((State, State) -> State?)?
|
|
||||||
|
|
||||||
function atom:get<Result>(selector: ((State) -> Result)?): any
|
|
||||||
return if selector then selector(state) else state
|
|
||||||
end
|
|
||||||
|
|
||||||
function atom:set(update: State | ((State) -> State))
|
|
||||||
local nextState = if type(update) == "function" then update(state) else update
|
|
||||||
|
|
||||||
if setter then
|
|
||||||
local result = setter(nextState, state)
|
|
||||||
|
|
||||||
if result ~= nil then
|
|
||||||
nextState = result
|
|
||||||
else
|
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
return state
|
||||||
end
|
end
|
||||||
|
|
||||||
if not (if equals then equals(state, nextState) else state == nextState) then
|
local nextState = if type(...) == "function" then (...)(state) else ...
|
||||||
|
|
||||||
|
if state ~= nextState and not (equals and equals(state, nextState)) then
|
||||||
state = nextState
|
state = nextState
|
||||||
|
|
||||||
for listener in table.clone(self.listeners) do
|
for listener in pairs(table.clone(store.listeners[atom])) do
|
||||||
listener(state)
|
listener()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
return state
|
||||||
end
|
end
|
||||||
|
|
||||||
function atom:setForced(update: State | ((State) -> State))
|
store.listeners[atom] = setmetatable({}, { __mode = "v" })
|
||||||
local nextState = if type(update) == "function" then update(state) else update
|
|
||||||
|
|
||||||
if not (if equals then equals(state, nextState) else state == nextState) then
|
|
||||||
state = nextState
|
|
||||||
|
|
||||||
for listener in table.clone(self.listeners) do
|
|
||||||
listener(state)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function atom:memo(areEqual: (State, State) -> boolean): Atom<State>
|
|
||||||
equals = areEqual
|
|
||||||
return self
|
|
||||||
end
|
|
||||||
|
|
||||||
function atom:writable(onSet: ((State, State) -> State?)?): Atom<State>
|
|
||||||
setter = onSet
|
|
||||||
return self
|
|
||||||
end
|
|
||||||
|
|
||||||
function atom:readonly(): ReadonlyAtom<State>
|
|
||||||
function setter(_, _)
|
|
||||||
if _G.__DEV__ then
|
|
||||||
error(`Attempt to set read-only atom '{self.debugLabel}' created at: {self.debugTraceback}`, 3)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return self
|
|
||||||
end
|
|
||||||
|
|
||||||
function atom:named(name: string): Atom<State>
|
|
||||||
self.debugLabel = name
|
|
||||||
return self
|
|
||||||
end
|
|
||||||
|
|
||||||
function atom:unmount()
|
|
||||||
table.clear(self.listeners)
|
|
||||||
table.clear(self.owners)
|
|
||||||
reference.release(self)
|
|
||||||
end
|
|
||||||
|
|
||||||
setmetatable(atom :: AtomInternal<State>, {
|
|
||||||
__type = "Atom",
|
|
||||||
__call = function(self, ...)
|
|
||||||
if select("#", ...) > 0 then
|
|
||||||
return self:set((...))
|
|
||||||
else
|
|
||||||
return self:get()
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
|
|
||||||
return atom
|
return atom
|
||||||
end
|
end
|
||||||
|
|
|
||||||
25
src/capture.luau
Normal file
25
src/capture.luau
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
local store = require(script.Parent.store)
|
||||||
|
local types = require(script.Parent.types)
|
||||||
|
type Atom<T> = types.Atom<T>
|
||||||
|
type Source<T> = types.Source<T>
|
||||||
|
|
||||||
|
local function capture<T>(source: Source<T>): ({ [Atom<any>]: true }, T)
|
||||||
|
if store.listeners[source] then
|
||||||
|
return { [source] = true }, source()
|
||||||
|
end
|
||||||
|
|
||||||
|
store.setCapturing(true)
|
||||||
|
local success, result = pcall(source)
|
||||||
|
store.setCapturing(false)
|
||||||
|
|
||||||
|
local dependencies = table.clone(store.captured)
|
||||||
|
table.clear(store.captured)
|
||||||
|
|
||||||
|
if not success then
|
||||||
|
warn("An error occurred while calling an atom:", result)
|
||||||
|
end
|
||||||
|
|
||||||
|
return dependencies, result
|
||||||
|
end
|
||||||
|
|
||||||
|
return capture
|
||||||
|
|
@ -1,65 +1,27 @@
|
||||||
local atom = require(script.Parent.atom)
|
local atom = require(script.Parent.atom)
|
||||||
type Atom<T> = atom.Atom<T>
|
local capture = require(script.Parent.capture)
|
||||||
type ReadonlyAtom<T> = atom.ReadonlyAtom<T>
|
local store = require(script.Parent.store)
|
||||||
local reference = require(script.Parent.utils.reference)
|
local types = require(script.Parent.types)
|
||||||
|
type Atom<T> = types.Atom<T>
|
||||||
|
type AtomOptions<T> = types.AtomOptions<T>
|
||||||
|
type Source<T> = types.Source<T>
|
||||||
|
|
||||||
type Derive =
|
local function derive<T>(source: Source<T>, options: AtomOptions<T>?): Source<T>
|
||||||
(<A, T>(Atom<A>, (A) -> T) -> ReadonlyAtom<T>)
|
local atoms, state = capture(source)
|
||||||
& (<A, B, T>(Atom<A>, Atom<B>, (A, B) -> T) -> ReadonlyAtom<T>)
|
local derived = atom(state, options)
|
||||||
& (<A, B, C, T>(Atom<A>, Atom<B>, Atom<C>, (A, B, C) -> T) -> ReadonlyAtom<T>)
|
local derivedRef = setmetatable({ current = derived }, { __mode = "v" })
|
||||||
& (<A, B, C, D, T>(Atom<A>, Atom<B>, Atom<C>, Atom<D>, (A, B, C, D) -> T) -> ReadonlyAtom<T>)
|
|
||||||
& (<A, B, C, D, E, T>(Atom<A>, Atom<B>, Atom<C>, Atom<D>, Atom<E>, (A, B, C, D, E) -> T) -> ReadonlyAtom<T>)
|
|
||||||
& (<T>(...(Atom<any> | ((...any) -> T))) -> ReadonlyAtom<T>)
|
|
||||||
|
|
||||||
local function derive<Result>(...): ReadonlyAtom<Result>
|
local function listener()
|
||||||
local atoms: { Atom<unknown> } = { ... }
|
if derivedRef.current then
|
||||||
local combiner: (...unknown) -> Result = table.remove(atoms) :: any
|
derivedRef.current(source())
|
||||||
local size = 0
|
|
||||||
local dependencies: { unknown } = {}
|
|
||||||
local listeners: { [() -> ()]: boolean } = {}
|
|
||||||
|
|
||||||
for index, atom in atoms do
|
|
||||||
dependencies[index] = atom:get()
|
|
||||||
size += 1
|
|
||||||
end
|
|
||||||
|
|
||||||
local derivedAtom = atom(combiner(table.unpack(dependencies, 1, size))):readonly()
|
|
||||||
local derivedAtomUnmount = derivedAtom.unmount
|
|
||||||
local getDerivedAtom = reference.weak(derivedAtom)
|
|
||||||
|
|
||||||
function derivedAtom:unmount()
|
|
||||||
derivedAtomUnmount(self)
|
|
||||||
|
|
||||||
for listener in listeners do
|
|
||||||
for _, atom in atoms do
|
|
||||||
atom.listeners[listener] = nil
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
for index, atom in atoms do
|
for atom in atoms do
|
||||||
local function listener(state: unknown)
|
store.listeners[atom][listener] = derived
|
||||||
-- Remove the listener if the derived atom has been garbage collected,
|
|
||||||
-- typically because no subscriptions to it exist.
|
|
||||||
local derivedAtom = getDerivedAtom()
|
|
||||||
|
|
||||||
if derivedAtom then
|
|
||||||
dependencies[index] = state
|
|
||||||
derivedAtom:setForced(combiner(table.unpack(dependencies, 1, size)))
|
|
||||||
else
|
|
||||||
atom.listeners[listener] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Tie the lifetime of the derived atom to the lifetime of its dependencies
|
|
||||||
-- without creating a strong reference cycle. As long as an atom is in use,
|
|
||||||
-- its derived atoms will be in use as well.
|
|
||||||
derivedAtom.owners[atom] = true
|
|
||||||
atom.listeners[listener] = true
|
|
||||||
listeners[listener] = true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return derivedAtom
|
return derived
|
||||||
end
|
end
|
||||||
|
|
||||||
return derive :: Derive
|
return derive
|
||||||
|
|
|
||||||
22
src/effect.luau
Normal file
22
src/effect.luau
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
local capture = require(script.Parent.capture)
|
||||||
|
local store = require(script.Parent.store)
|
||||||
|
|
||||||
|
local function effect(source: () -> nil): () -> ()
|
||||||
|
local atoms = capture(source)
|
||||||
|
|
||||||
|
local function listener()
|
||||||
|
source()
|
||||||
|
end
|
||||||
|
|
||||||
|
for atom in atoms do
|
||||||
|
store.listeners[atom][listener] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
return function()
|
||||||
|
for atom in atoms do
|
||||||
|
store.listeners[atom][listener] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return effect
|
||||||
159
src/index.d.ts
vendored
159
src/index.d.ts
vendored
|
|
@ -1,134 +1,109 @@
|
||||||
export = Charm;
|
export = Charm;
|
||||||
export as namespace Charm;
|
export as namespace Charm;
|
||||||
|
|
||||||
|
type Cleanup = () => void;
|
||||||
|
|
||||||
|
type AnyMap<K, V> =
|
||||||
|
| Map<K, V>
|
||||||
|
| ReadonlyMap<K, V>
|
||||||
|
| (K extends string | number | symbol ? { readonly [Key in K]: V } : never);
|
||||||
|
|
||||||
declare namespace Charm {
|
declare namespace Charm {
|
||||||
interface InternalAtom<State> {
|
interface Atom<T> extends Source<T> {
|
||||||
/** @hidden */
|
readonly __nominal: unique symbol;
|
||||||
readonly listeners: Set<(value: State) => void>;
|
(state: T | ((prev: T) => T)): void;
|
||||||
/** @hidden */
|
|
||||||
readonly debugTraceback: string;
|
|
||||||
/** @hidden */
|
|
||||||
readonly debugLabel: string;
|
|
||||||
/** @hidden */
|
|
||||||
setForced(state: State | ((current: State) => State)): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseAtom<State> extends InternalAtom<State> {
|
type Source<T> = () => T;
|
||||||
get(): State;
|
|
||||||
get<Result>(selector: (state: State) => Result): Result;
|
interface AtomOptions<State> {
|
||||||
set(state: State | ((current: State) => State)): void;
|
equals?: (prev: State, next: State) => boolean;
|
||||||
memo(areEqual: (previous: State, current: State) => boolean): this;
|
|
||||||
writable(onSet?: (update: State, previous: State) => State | void): Atom<State>;
|
|
||||||
readonly(): ReadonlyAtom<State>;
|
|
||||||
named(label: string): this;
|
|
||||||
unmount(): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Atom<State> extends BaseAtom<State> {
|
function atom<State>(state: State, options?: AtomOptions<State>): Atom<State>;
|
||||||
(): State;
|
|
||||||
(state: State | ((previous: State) => State)): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReadonlyAtom<State> extends BaseAtom<State> {
|
|
||||||
(): State;
|
|
||||||
set: never;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AtomArrayToStates<Atoms extends Atom<any>[]> = {
|
|
||||||
[Key in keyof Atoms]: Atoms[Key] extends Atom<infer State> ? State : never;
|
|
||||||
};
|
|
||||||
|
|
||||||
function atom<State>(state: State): Atom<State>;
|
|
||||||
|
|
||||||
function isAtom(value: unknown): value is Atom<any>;
|
function isAtom(value: unknown): value is Atom<any>;
|
||||||
|
|
||||||
function derive<Atoms extends Atom<any>[], Result>(
|
function derive<State>(atom: Source<State>, options?: AtomOptions<State>): Source<State>;
|
||||||
...args: [...atoms: Atoms, combiner: (...atoms: AtomArrayToStates<Atoms>) => Result]
|
|
||||||
): ReadonlyAtom<Result>;
|
|
||||||
|
|
||||||
function subscribe<State>(atom: Atom<State>, callback: (state: State) => void): () => void;
|
function subscribe<State>(atom: Source<State>, callback: (state: State, prev: State) => void): Cleanup;
|
||||||
|
|
||||||
function observe<K, V>(
|
function effect(callback: Source<void>): Cleanup;
|
||||||
atom: Atom<Map<K, V> | ReadonlyMap<K, V>>,
|
|
||||||
factory: (value: V, key: K) => (() => void) | void,
|
|
||||||
): () => void;
|
|
||||||
|
|
||||||
function observe(atom: Atom<any>, factory: (value: unknown, key: unknown) => (() => void) | void): () => void;
|
function unwrap<State>(atom: State | Source<State>): State;
|
||||||
|
|
||||||
function mapAtom<V0, K1, V1>(
|
function capture<State>(atom: Source<State>): LuaTuple<[captured: Set<Atom<unknown>>, state: State]>;
|
||||||
atom: Atom<readonly V0[]> | Atom<V0[]>,
|
|
||||||
mapper: (value: V0, index: number) => LuaTuple<[V1 | undefined, K1]>,
|
|
||||||
): ReadonlyAtom<ReadonlyMap<K1, V1>>;
|
|
||||||
|
|
||||||
function mapAtom<V0, V1>(
|
function observe<Item>(
|
||||||
atom: Atom<readonly V0[]> | Atom<V0[]>,
|
atom: Source<readonly Item[]>,
|
||||||
mapper: (value: V0, index: number) => V1,
|
factory: (item: Item, index: number) => Cleanup | void,
|
||||||
): ReadonlyAtom<readonly V1[]>;
|
): Cleanup;
|
||||||
|
|
||||||
function mapAtom<K0, V0, K1 = K0, V1 = V0>(
|
function observe<Key, Item>(
|
||||||
atom: Atom<ReadonlyMap<K0, V0>> | Atom<Map<K0, V0>>,
|
atom: Source<AnyMap<Key, Item>>,
|
||||||
mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1,
|
factory: (item: Item, key: Key) => Cleanup | void,
|
||||||
): ReadonlyAtom<ReadonlyMap<K1, V1>>;
|
): Cleanup;
|
||||||
|
|
||||||
function mapAtom<K0 extends string | number | symbol, V0, K1, V1>(
|
function map<V0, K1, V1>(
|
||||||
atom: Atom<{ readonly [K in K0]: V0 }>,
|
atom: Source<readonly V0[]>,
|
||||||
mapper: (value: V0, key: K0) => LuaTuple<[V1 | undefined, K1]> | V1,
|
mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>,
|
||||||
): ReadonlyAtom<ReadonlyMap<K1, V1>>;
|
): Source<ReadonlyMap<K1, V1>>;
|
||||||
|
|
||||||
function useAtomState<State>(atom: Atom<State>): State;
|
function map<V0, V1>(atom: Source<readonly V0[]>, mapper: (value: V0, index: number) => V1): Source<readonly V1[]>;
|
||||||
|
|
||||||
function useAtomState<State, Result>(atom: Atom<State>, selector: (state: State) => Result): Result;
|
function map<K0, V0, K1 = K0, V1 = V0>(
|
||||||
|
atom: Source<AnyMap<K0, V0>>,
|
||||||
|
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||||
|
): Source<ReadonlyMap<K1, V1>>;
|
||||||
|
|
||||||
function useSetAtom<State>(atom: Atom<State>): (state: State | ((previous: State) => State)) => void;
|
// React
|
||||||
|
|
||||||
|
function useAtomState<State>(atom: Source<State>): State;
|
||||||
|
|
||||||
|
function useAtomState<State, Result>(atom: Source<State>, selector: (state: State) => Result): Result;
|
||||||
|
|
||||||
|
function useSetAtom<State>(atom: Source<State>): (state: State | ((prev: State) => State)) => void;
|
||||||
|
|
||||||
function useAtom<State>(
|
function useAtom<State>(
|
||||||
atom: Atom<State>,
|
atom: Source<State>,
|
||||||
): LuaTuple<[state: State, setState: (state: State | ((previous: State) => State)) => void]>;
|
): LuaTuple<[state: State, setState: (state: State | ((prev: State) => State)) => void]>;
|
||||||
|
|
||||||
function useAtom<State, Result>(
|
function useAtom<State, Result>(
|
||||||
atom: Atom<State>,
|
atom: Source<State>,
|
||||||
selector: (state: State) => Result,
|
selector: (state: State) => Result,
|
||||||
): LuaTuple<[state: Result, setState: (state: Result | ((previous: Result) => Result)) => void]>;
|
): LuaTuple<[state: Result, setState: (state: Result | ((prev: Result) => Result)) => void]>;
|
||||||
|
|
||||||
interface None {
|
// Sync
|
||||||
readonly __none: "__none";
|
|
||||||
}
|
|
||||||
|
|
||||||
type StatePatch<States> = {
|
const sync: {
|
||||||
readonly [K in keyof States]?: (States[K] extends object ? StatePatch<States[K]> : States[K]) | None;
|
client(options: ClientOptions): ClientSyncer;
|
||||||
|
server(options: ServerOptions): ServerSyncer;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SyncPayload<States> = { type: "set"; data: States } | { type: "patch"; data: StatePatch<States> };
|
interface SyncPatch {
|
||||||
|
[key: string | number | symbol]: SyncPatch | { __none: "__none" } | string | number | boolean | undefined;
|
||||||
interface AtomMap {
|
|
||||||
readonly [key: string]: Atom<any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AtomMapToStates<Atoms extends AtomMap> = {
|
interface SyncPayload {
|
||||||
readonly [Key in keyof Atoms]: Atoms[Key] extends Atom<infer State> ? State : never;
|
type: "set" | "patch";
|
||||||
};
|
data: SyncPatch;
|
||||||
|
|
||||||
interface ClientSyncOptions<Atoms extends AtomMap> {
|
|
||||||
atoms: Atoms;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientSyncer<States> {
|
interface ClientOptions {
|
||||||
sync(payload: SyncPayload<States>): void;
|
atoms: Record<string, Atom<any>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServerSyncOptions<Atoms extends AtomMap> {
|
interface ServerOptions {
|
||||||
atoms: Atoms;
|
atoms: Record<string, Atom<any>>;
|
||||||
interval?: number;
|
interval?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServerSyncer<States> {
|
interface ClientSyncer {
|
||||||
onSync(callback: (player: Player, payload: SyncPayload<States>) => void): () => void;
|
sync(payload: SyncPayload): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerSyncer {
|
||||||
|
connect(callback: (player: Player, payload: SyncPayload) => void): Cleanup;
|
||||||
hydrate(player: Player): void;
|
hydrate(player: Player): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace sync {
|
|
||||||
function client<Atoms extends AtomMap>(options: ClientSyncOptions<Atoms>): ClientSyncer<AtomMapToStates<Atoms>>;
|
|
||||||
|
|
||||||
function server<Atoms extends AtomMap>(options: ServerSyncOptions<Atoms>): ServerSyncer<AtomMapToStates<Atoms>>;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,34 @@
|
||||||
_G[script.modules.Promise] = _G[script]
|
_G[script.modules.Promise] = _G[script]
|
||||||
|
|
||||||
local atom = require(script.atom)
|
local atom = require(script.atom)
|
||||||
|
local capture = require(script.capture)
|
||||||
local derive = require(script.derive)
|
local derive = require(script.derive)
|
||||||
|
local effect = require(script.effect)
|
||||||
local isAtom = require(script.isAtom)
|
local isAtom = require(script.isAtom)
|
||||||
local mapAtom = require(script.mapAtom)
|
local map = require(script.map)
|
||||||
local observe = require(script.observe)
|
local observe = require(script.observe)
|
||||||
local useAtom = require(script.react.useAtom)
|
local useAtom = require(script.react.useAtom)
|
||||||
local useAtomState = require(script.react.useAtomState)
|
local useAtomState = require(script.react.useAtomState)
|
||||||
local useSetAtom = require(script.react.useSetAtom)
|
local useSetAtom = require(script.react.useSetAtom)
|
||||||
local subscribe = require(script.subscribe)
|
local subscribe = require(script.subscribe)
|
||||||
local sync = require(script.sync)
|
local sync = require(script.sync)
|
||||||
|
local types = require(script.types)
|
||||||
|
local unwrap = require(script.unwrap)
|
||||||
|
|
||||||
export type Atom<State> = atom.Atom<State>
|
export type Atom<State> = types.Atom<State>
|
||||||
export type ReadonlyAtom<State> = atom.ReadonlyAtom<State>
|
export type Source<State> = types.Source<State>
|
||||||
|
export type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
return {
|
return {
|
||||||
atom = atom,
|
atom = atom,
|
||||||
derive = derive,
|
derive = derive,
|
||||||
|
effect = effect,
|
||||||
isAtom = isAtom,
|
isAtom = isAtom,
|
||||||
|
map = map,
|
||||||
observe = observe,
|
observe = observe,
|
||||||
mapAtom = mapAtom,
|
|
||||||
subscribe = subscribe,
|
subscribe = subscribe,
|
||||||
|
unwrap = unwrap,
|
||||||
|
capture = capture,
|
||||||
sync = sync,
|
sync = sync,
|
||||||
useAtom = useAtom,
|
useAtom = useAtom,
|
||||||
useAtomState = useAtomState,
|
useAtomState = useAtomState,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
local store = require(script.Parent.store)
|
||||||
|
|
||||||
local function isAtom(value: any)
|
local function isAtom(value: any)
|
||||||
return type(value) == "table" and getmetatable(value) and getmetatable(value).__type == "Atom" or false
|
return value ~= nil and store.listeners[value] :: any ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
return isAtom
|
return isAtom
|
||||||
|
|
|
||||||
54
src/map.luau
Normal file
54
src/map.luau
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
local atom = require(script.Parent.atom)
|
||||||
|
local subscribe = require(script.Parent.subscribe)
|
||||||
|
local types = require(script.Parent.types)
|
||||||
|
type Atom<T> = types.Atom<T>
|
||||||
|
type Source<T> = types.Source<T>
|
||||||
|
|
||||||
|
type Map =
|
||||||
|
(<K0, V0, K1, V1>(atom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1)) -> Source<{ [K1]: V1 }>)
|
||||||
|
& (<K0, V0, V1>(atom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Source<{ [K0]: V1 }>)
|
||||||
|
& (<K0, V0, K1, V1>(atom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Source<{ [K1]: V1 }>)
|
||||||
|
|
||||||
|
local function map<K0, V0, K1, V1>(itemsAtom: Source<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Source<{ [K1]: V1 }>
|
||||||
|
local mappedAtom = atom({})
|
||||||
|
local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" })
|
||||||
|
local prevMappedItems: { [K1]: V1 } = {}
|
||||||
|
local unsubscribe
|
||||||
|
|
||||||
|
local function listener(items: { [K0]: V0 })
|
||||||
|
if not mappedAtomRef.current then
|
||||||
|
return unsubscribe()
|
||||||
|
end
|
||||||
|
|
||||||
|
local mappedItems = table.clone(mappedAtomRef.current())
|
||||||
|
local mappedKeys = {}
|
||||||
|
|
||||||
|
for key, item in items do
|
||||||
|
local newItem, newKey = mapper(item, key)
|
||||||
|
if newKey == nil then
|
||||||
|
newKey = key :: any
|
||||||
|
end
|
||||||
|
if mappedItems[newKey :: K1] ~= newItem then
|
||||||
|
mappedItems[newKey :: K1] = newItem :: V1
|
||||||
|
else
|
||||||
|
mappedKeys[newKey] = key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for key in prevMappedItems do
|
||||||
|
if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then
|
||||||
|
mappedItems[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
prevMappedItems = mappedItems
|
||||||
|
mappedAtom(mappedItems)
|
||||||
|
end
|
||||||
|
|
||||||
|
unsubscribe = subscribe(itemsAtom, listener)
|
||||||
|
listener(itemsAtom())
|
||||||
|
|
||||||
|
return mappedAtom
|
||||||
|
end
|
||||||
|
|
||||||
|
return map :: Map
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
local atom = require(script.Parent.atom)
|
|
||||||
type Atom<T> = atom.Atom<T>
|
|
||||||
type ReadonlyAtom<T> = atom.ReadonlyAtom<T>
|
|
||||||
local reference = require(script.Parent.utils.reference)
|
|
||||||
|
|
||||||
local function mapAtom<K0, V0, K1, V1>(itemsAtom: Atom<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): ReadonlyAtom<{ [K1]: V1 }>
|
|
||||||
local mappedAtom: ReadonlyAtom<{ [K1]: V1 }> = atom({}):readonly()
|
|
||||||
local mappedAtomUnmount = mappedAtom.unmount
|
|
||||||
local getMappedAtom = reference.weak(mappedAtom)
|
|
||||||
local previousMappedItems: { [K1]: V1 } = {}
|
|
||||||
|
|
||||||
local function listener(items)
|
|
||||||
local mappedAtom = getMappedAtom()
|
|
||||||
|
|
||||||
if not mappedAtom then
|
|
||||||
itemsAtom.listeners[listener] = nil
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local mappedItems = table.clone(mappedAtom:get())
|
|
||||||
local mappedKeys = {}
|
|
||||||
|
|
||||||
for key, item in items do
|
|
||||||
local newItem, newKey = mapper(item, key)
|
|
||||||
if newKey == nil then
|
|
||||||
newKey = key :: any
|
|
||||||
end
|
|
||||||
if mappedItems[newKey :: K1] ~= newItem then
|
|
||||||
mappedItems[newKey :: K1] = newItem :: V1
|
|
||||||
else
|
|
||||||
mappedKeys[newKey] = key
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
for key in previousMappedItems do
|
|
||||||
if mappedKeys[key] == nil and mappedItems[key] == previousMappedItems[key] then
|
|
||||||
mappedItems[key] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
previousMappedItems = mappedItems
|
|
||||||
mappedAtom:setForced(mappedItems)
|
|
||||||
end
|
|
||||||
|
|
||||||
function mappedAtom:unmount()
|
|
||||||
mappedAtomUnmount(self)
|
|
||||||
itemsAtom.listeners[listener] = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
itemsAtom.listeners[listener] = true
|
|
||||||
listener(itemsAtom:get())
|
|
||||||
|
|
||||||
return mappedAtom
|
|
||||||
end
|
|
||||||
|
|
||||||
return mapAtom
|
|
||||||
|
|
@ -5,4 +5,11 @@ local Packages = if Package.Parent:FindFirstChild("React")
|
||||||
|
|
||||||
type React = typeof(require(Package.Parent.React))
|
type React = typeof(require(Package.Parent.React))
|
||||||
|
|
||||||
return (if Packages then require(Packages.React) else nil) :: React
|
local React: React
|
||||||
|
|
||||||
|
return function()
|
||||||
|
if not React then
|
||||||
|
React = Packages and require(Packages.React) :: React
|
||||||
|
end
|
||||||
|
return React
|
||||||
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
local atom = require(script.Parent.atom)
|
local effect = require(script.Parent.effect)
|
||||||
type Atom<T> = atom.Atom<T>
|
local types = require(script.Parent.types)
|
||||||
local subscribe = require(script.Parent.subscribe)
|
type Source<T> = types.Source<T>
|
||||||
|
|
||||||
local function observe<Item, Key>(atom: Atom<{ [Key]: Item }>, factory: (value: Item, key: Key) -> () -> ()): (() -> (), () -> ())
|
local function observe<K, V>(source: Source<{ [K]: V }>, factory: (value: V, key: K) -> () -> ()): () -> ()
|
||||||
local connections: { [Key]: () -> () } = {}
|
local connections: { [K]: () -> () } = {}
|
||||||
|
|
||||||
|
local unsubscribe = effect(function()
|
||||||
|
local state = source()
|
||||||
|
|
||||||
local function update(state: { [Key]: Item })
|
|
||||||
for key, disconnect in connections do
|
for key, disconnect in connections do
|
||||||
if state[key] == nil then
|
if state[key] == nil then
|
||||||
connections[key] = nil
|
connections[key] = nil
|
||||||
|
|
@ -18,9 +20,7 @@ local function observe<Item, Key>(atom: Atom<{ [Key]: Item }>, factory: (value:
|
||||||
connections[key] = factory(value, key)
|
connections[key] = factory(value, key)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end)
|
||||||
|
|
||||||
local unsubscribe, flush = subscribe(atom, update)
|
|
||||||
|
|
||||||
local function cleanup()
|
local function cleanup()
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
|
|
@ -29,9 +29,7 @@ local function observe<Item, Key>(atom: Atom<{ [Key]: Item }>, factory: (value:
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
update(atom:get())
|
return cleanup
|
||||||
|
|
||||||
return cleanup, flush
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return observe
|
return observe
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local types = require(script.Parent.Parent.types)
|
||||||
type Atom<T> = atom.Atom<T>
|
type Atom<T> = types.Atom<T>
|
||||||
|
|
||||||
local useAtomState = require(script.Parent.useAtomState)
|
local useAtomState = require(script.Parent.useAtomState)
|
||||||
local useSetAtom = require(script.Parent.useSetAtom)
|
local useSetAtom = require(script.Parent.useSetAtom)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
local React = require(script.Parent.Parent.modules.React)
|
local requireReact = require(script.Parent.Parent.modules.React)
|
||||||
|
local subscribe = require(script.Parent.Parent.subscribe)
|
||||||
|
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local types = require(script.Parent.Parent.types)
|
||||||
type Atom<T> = atom.Atom<T>
|
type Atom<T> = types.Atom<T>
|
||||||
|
|
||||||
type UseAtomState =
|
type UseAtomState =
|
||||||
(<State>(atom: Atom<State>) -> State)
|
(<State>(atom: Atom<State>) -> State)
|
||||||
|
|
@ -9,24 +10,20 @@ type UseAtomState =
|
||||||
& (<State, Result>(atom: Atom<State>, selector: (State) -> Result) -> Result)
|
& (<State, Result>(atom: Atom<State>, selector: (State) -> Result) -> Result)
|
||||||
|
|
||||||
local function useAtomState<State>(atom: Atom<State>, selector: ((State) -> State)?): State
|
local function useAtomState<State>(atom: Atom<State>, selector: ((State) -> State)?): State
|
||||||
|
local React = requireReact()
|
||||||
|
|
||||||
local state, setState = React.useState(function()
|
local state, setState = React.useState(function()
|
||||||
return atom:get(selector :: (State) -> State)
|
return if selector then atom(selector) else atom()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
local latestSelector = React.useRef(selector)
|
local latestSelector = React.useRef(selector)
|
||||||
latestSelector.current = selector
|
latestSelector.current = selector
|
||||||
|
|
||||||
React.useEffect(function()
|
React.useEffect(function()
|
||||||
local function listener(state: State)
|
return subscribe(atom, function(state)
|
||||||
local selector = latestSelector.current
|
local selector = latestSelector.current
|
||||||
setState(if selector then selector(state) else state)
|
setState(if selector then selector(state) else state)
|
||||||
end
|
end)
|
||||||
|
|
||||||
atom.listeners[listener] = true
|
|
||||||
|
|
||||||
return function()
|
|
||||||
atom.listeners[listener] = nil
|
|
||||||
end
|
|
||||||
end, { atom })
|
end, { atom })
|
||||||
|
|
||||||
return state
|
return state
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
local React = require(script.Parent.Parent.modules.React)
|
local requireReact = require(script.Parent.Parent.modules.React)
|
||||||
|
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local types = require(script.Parent.Parent.types)
|
||||||
type Atom<T> = atom.Atom<T>
|
type Atom<T> = types.Atom<T>
|
||||||
|
|
||||||
export type SetState<T> = (T | ((T) -> T)) -> ()
|
export type SetState<T> = (T | ((T) -> T)) -> ()
|
||||||
|
|
||||||
local function useSetAtom<State>(atom: Atom<State>): SetState<State>
|
local function useSetAtom<State>(atom: Atom<State>): SetState<State>
|
||||||
return React.useCallback(function(value)
|
return requireReact().useCallback(function(state: State | ((State) -> State)): ()
|
||||||
atom:set(value)
|
atom(state)
|
||||||
end, { atom })
|
end, { atom })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
23
src/store.luau
Normal file
23
src/store.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
local types = require(script.Parent.types)
|
||||||
|
type Atom<T> = types.Atom<T>
|
||||||
|
type Source<T> = types.Source<T>
|
||||||
|
type WeakMap<K, V> = types.WeakMap<K, V>
|
||||||
|
|
||||||
|
local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" })
|
||||||
|
local captured: { [Atom<any>]: true } = {}
|
||||||
|
local capturing = false
|
||||||
|
|
||||||
|
local function setCapturing(value: boolean)
|
||||||
|
capturing = value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shouldCapture()
|
||||||
|
return capturing
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
listeners = listeners,
|
||||||
|
captured = captured,
|
||||||
|
setCapturing = setCapturing,
|
||||||
|
shouldCapture = shouldCapture,
|
||||||
|
}
|
||||||
|
|
@ -1,45 +1,26 @@
|
||||||
local atom = require(script.Parent.atom)
|
local capture = require(script.Parent.capture)
|
||||||
type Atom<T> = atom.Atom<T>
|
local store = require(script.Parent.store)
|
||||||
local reference = require(script.Parent.utils.reference)
|
local types = require(script.Parent.types)
|
||||||
|
type Source<T> = types.Source<T>
|
||||||
|
|
||||||
local function subscribe<State>(atom: Atom<State>, callback: (state: State, previous: State) -> ()): (() -> (), () -> ())
|
local function subscribe<T>(source: Source<T>, callback: (state: T, prev: T) -> ()): () -> ()
|
||||||
local pending: thread? = nil
|
local atoms, state = capture(source)
|
||||||
local state = atom:get()
|
|
||||||
|
|
||||||
local function emit()
|
local function listener()
|
||||||
local lastState = state
|
local prevState = state
|
||||||
state = atom:get()
|
state = source()
|
||||||
callback(state, lastState)
|
callback(state, prevState)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function update()
|
for atom in atoms do
|
||||||
pending = pending or task.defer(function()
|
store.listeners[atom][listener] = true
|
||||||
pending = nil
|
|
||||||
emit()
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function flush()
|
return function()
|
||||||
if pending then
|
for atom in atoms do
|
||||||
pending = task.cancel(pending)
|
store.listeners[atom][listener] = nil
|
||||||
emit()
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function unsubscribe()
|
|
||||||
reference.release(atom)
|
|
||||||
atom.listeners[update] = nil
|
|
||||||
|
|
||||||
if pending then
|
|
||||||
pending = task.cancel(pending)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Ensure the atom is held in memory while the subscription is active
|
|
||||||
reference.hold(atom)
|
|
||||||
atom.listeners[update] = true
|
|
||||||
|
|
||||||
return unsubscribe, flush
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return subscribe
|
return subscribe
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local types = require(script.Parent.Parent.types)
|
||||||
type Atom<T> = atom.Atom<T>
|
type Atom<T> = types.Atom<T>
|
||||||
|
type SyncPayload = types.SyncPayload
|
||||||
local patch = require(script.Parent.patch)
|
local patch = require(script.Parent.patch)
|
||||||
local types = require(script.Parent.types)
|
|
||||||
|
|
||||||
type ClientSyncOptions = {
|
type ClientOptions = {
|
||||||
atoms: { [string]: Atom<any> },
|
atoms: { [string]: Atom<any> },
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientSyncer = {
|
type ClientSyncer = {
|
||||||
sync: (self: ClientSyncer, payload: types.SyncPayload) -> (),
|
sync: (self: ClientSyncer, payload: SyncPayload) -> (),
|
||||||
}
|
}
|
||||||
|
|
||||||
local function client(options: ClientSyncOptions): ClientSyncer
|
local function client(options: ClientOptions): ClientSyncer
|
||||||
local atoms = options.atoms
|
local atoms = options.atoms
|
||||||
local self = {} :: ClientSyncer
|
local self = {} :: ClientSyncer
|
||||||
|
|
||||||
function self:sync(payload)
|
function self:sync(payload)
|
||||||
for key, state in payload.data do
|
for key, state in payload.data do
|
||||||
if payload.type == "patch" then
|
if payload.type == "patch" then
|
||||||
atoms[key]:set(patch.apply(atoms[key]:get(), state))
|
atoms[key](patch.apply(atoms[key](), state))
|
||||||
else
|
else
|
||||||
atoms[key]:set(state)
|
atoms[key](state)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
local client = require(script.client)
|
local client = require(script.client)
|
||||||
local server = require(script.server)
|
local server = require(script.server)
|
||||||
local types = require(script.types)
|
|
||||||
|
|
||||||
export type SyncPayload = types.SyncPayload
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client = client,
|
client = client,
|
||||||
|
|
|
||||||
|
|
@ -2,43 +2,48 @@ local validate = require(script.Parent.validate)
|
||||||
|
|
||||||
local NONE = { __none = "__none" }
|
local NONE = { __none = "__none" }
|
||||||
|
|
||||||
local function diff(previousState: { [any]: any }, nextState: { [any]: any })
|
local warnings = {}
|
||||||
local patch = table.clone(nextState)
|
|
||||||
|
|
||||||
for key, previous in previousState do
|
local function diff(prevState: { [any]: any }, nextState: { [any]: any })
|
||||||
|
local patches = table.clone(nextState)
|
||||||
|
|
||||||
|
for key, previous in prevState do
|
||||||
local next = nextState[key]
|
local next = nextState[key]
|
||||||
|
|
||||||
if previous == next then
|
if previous == next then
|
||||||
patch[key] = nil
|
patches[key] = nil
|
||||||
elseif next == nil then
|
elseif next == nil then
|
||||||
patch[key] = NONE
|
patches[key] = NONE
|
||||||
elseif type(previous) == "table" and type(next) == "table" then
|
elseif type(previous) == "table" and type(next) == "table" then
|
||||||
patch[key] = diff(previous, next)
|
patches[key] = diff(previous, next)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if _G.__DEV__ then
|
if _G.__DEV__ then
|
||||||
local success, result = pcall(function()
|
local success, result = pcall(function()
|
||||||
for key, value in previousState do
|
for key, value in prevState do
|
||||||
validate(value, key)
|
validate(value, key)
|
||||||
end
|
end
|
||||||
for key, value in nextState do
|
for key, value in nextState do
|
||||||
validate(value, key)
|
if prevState[key] ~= value then
|
||||||
|
validate(value, key)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
if not success then
|
if not success and not warnings[result] then
|
||||||
|
warnings[result] = true
|
||||||
warn(result)
|
warn(result)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return patch
|
return patches
|
||||||
end
|
end
|
||||||
|
|
||||||
local function apply(state: { [any]: any }, patchState: { [any]: any })
|
local function apply(state: { [any]: any }, patches: { [any]: any })
|
||||||
local nextState = table.clone(state)
|
local nextState = table.clone(state)
|
||||||
|
|
||||||
for key, patch in patchState do
|
for key, patch in patches do
|
||||||
local current = nextState[key]
|
local current = nextState[key]
|
||||||
|
|
||||||
if type(patch) == "table" and patch.__none == "__none" then
|
if type(patch) == "table" and patch.__none == "__none" then
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,69 @@
|
||||||
local Players = game:GetService("Players")
|
local Players = game:GetService("Players")
|
||||||
|
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local types = require(script.Parent.Parent.types)
|
||||||
type Atom<T> = atom.Atom<T>
|
type Atom<T> = types.Atom<T>
|
||||||
|
type SyncPayload = types.SyncPayload
|
||||||
local setInterval = require(script.Parent.Parent.utils.setInterval)
|
local setInterval = require(script.Parent.Parent.utils.setInterval)
|
||||||
local patch = require(script.Parent.patch)
|
local patch = require(script.Parent.patch)
|
||||||
local types = require(script.Parent.types)
|
|
||||||
|
|
||||||
type ServerSyncOptions = {
|
type ServerOptions = {
|
||||||
atoms: { [string]: Atom<any> },
|
atoms: { [string]: Atom<any> },
|
||||||
interval: number?,
|
interval: number?,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerSyncer = {
|
type ServerSyncer = {
|
||||||
onSync: (self: ServerSyncer, callback: (player: Player, payload: types.SyncPayload) -> ()) -> () -> (),
|
connect: (self: ServerSyncer, callback: (player: Player, payload: SyncPayload) -> ()) -> () -> (),
|
||||||
flush: (self: ServerSyncer, player: Player?) -> (),
|
flush: (self: ServerSyncer, player: Player) -> (),
|
||||||
hydrate: (self: ServerSyncer, player: Player) -> (),
|
hydrate: (self: ServerSyncer, player: Player) -> (),
|
||||||
}
|
}
|
||||||
|
|
||||||
local function server(options: ServerSyncOptions): ServerSyncer
|
local function server(options: ServerOptions): ServerSyncer
|
||||||
local atoms = options.atoms
|
local atoms = options.atoms
|
||||||
local interval = options.interval or 0
|
local interval = options.interval or 0
|
||||||
|
|
||||||
local self = {} :: ServerSyncer
|
local self = {} :: ServerSyncer
|
||||||
local sync: (player: Player, payload: types.SyncPayload) -> ()
|
local sync: (player: Player, payload: SyncPayload) -> ()
|
||||||
local state: {}
|
local state: {}
|
||||||
|
|
||||||
local function getState()
|
local function getState()
|
||||||
local state = {}
|
local state = {}
|
||||||
for key, atom in atoms do
|
for key, atom in atoms do
|
||||||
state[key] = atom:get()
|
state[key] = atom()
|
||||||
end
|
end
|
||||||
return state
|
return state
|
||||||
end
|
end
|
||||||
|
|
||||||
function self:onSync(callback)
|
function self:connect(callback)
|
||||||
sync = callback
|
sync = callback
|
||||||
state = getState()
|
state = getState()
|
||||||
|
|
||||||
return setInterval(function()
|
return setInterval(function()
|
||||||
self:flush()
|
local previous = state
|
||||||
|
state = getState()
|
||||||
|
|
||||||
|
if state ~= previous then
|
||||||
|
for _, player in Players:GetPlayers() do
|
||||||
|
callback(player, {
|
||||||
|
type = "patch",
|
||||||
|
data = patch.diff(previous, state),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
end, interval)
|
end, interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
function self:flush(player: Player?)
|
function self:flush(player: Player)
|
||||||
local current = getState()
|
local previous = state
|
||||||
local previous = state or current
|
state = getState()
|
||||||
state = current
|
|
||||||
|
|
||||||
if current ~= previous then
|
sync(player, {
|
||||||
local payload: types.SyncPayload = {
|
type = "patch",
|
||||||
type = "patch",
|
data = patch.diff(previous, state),
|
||||||
data = patch.diff(previous, current),
|
})
|
||||||
}
|
|
||||||
|
|
||||||
if player then
|
|
||||||
return sync(player, payload)
|
|
||||||
end
|
|
||||||
|
|
||||||
for _, player in Players:GetPlayers() do
|
|
||||||
sync(player, payload)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function self:hydrate(player)
|
function self:hydrate(player)
|
||||||
assert(sync, "onSync must be called before onJoin")
|
assert(sync, "connect() must be called before hydrate()")
|
||||||
|
|
||||||
sync(player, {
|
sync(player, {
|
||||||
type = "set",
|
type = "set",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export type SyncPayload = {
|
|
||||||
type: "patch" | "set",
|
|
||||||
data: { [any]: any },
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
16
src/types.luau
Normal file
16
src/types.luau
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export type WeakMap<K, V> = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" }))
|
||||||
|
|
||||||
|
export type Atom<T> = (() -> T) & (state: T | (T) -> T) -> T
|
||||||
|
|
||||||
|
export type Source<T> = () -> T
|
||||||
|
|
||||||
|
export type AtomOptions<T> = {
|
||||||
|
equals: (prev: T, next: T) -> boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncPayload = {
|
||||||
|
type: "patch" | "set",
|
||||||
|
data: { [any]: any },
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
23
src/unwrap.luau
Normal file
23
src/unwrap.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
local store = require(script.Parent.store)
|
||||||
|
|
||||||
|
local function unwrap<T>(atom: (() -> T) | T): T
|
||||||
|
if type(atom) ~= "function" then
|
||||||
|
return atom
|
||||||
|
end
|
||||||
|
|
||||||
|
if not store.shouldCapture() then
|
||||||
|
return atom()
|
||||||
|
end
|
||||||
|
|
||||||
|
store.setCapturing(false)
|
||||||
|
local success, result = pcall(atom)
|
||||||
|
store.setCapturing(true)
|
||||||
|
|
||||||
|
if not success then
|
||||||
|
error(`Failed to unwrap atom: ${result}`, 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
return unwrap
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
local refs = {}
|
|
||||||
|
|
||||||
--- Prevents the garbage collector from collecting a value
|
|
||||||
local function hold(value: unknown)
|
|
||||||
refs[value] = true
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Allows the garbage collector to collect a value
|
|
||||||
local function release(value: unknown)
|
|
||||||
refs[value] = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Clears all held references
|
|
||||||
local function clear()
|
|
||||||
table.clear(refs)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Returns a function that retrieves the value through a weak reference
|
|
||||||
local function weak<T>(value: T): () -> T?
|
|
||||||
local set = setmetatable({ value }, { __mode = "v" })
|
|
||||||
return function()
|
|
||||||
return set[1]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
|
||||||
hold = hold,
|
|
||||||
release = release,
|
|
||||||
clear = clear,
|
|
||||||
weak = weak,
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue