chore: housekeeping

This commit is contained in:
littensy 2025-05-21 12:16:29 -07:00
parent 4931515a95
commit 4edc952c05
77 changed files with 230 additions and 602 deletions

View file

@ -0,0 +1,6 @@
{
"name": "CharmSync",
"tree": {
"$path": "src"
}
}

View file

@ -0,0 +1,76 @@
local Charm = require(script.Parent.Parent.Charm)
local patch = require(script.Parent.patch)
local types = require(script.Parent.types)
type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload
type ClientOptions = {
--- The atoms to synchronize with the server.
atoms: AtomMap,
--- Whether to ignore patches sent before the client has been hydrated.
--- Default is `true`.
ignoreUnhydrated: boolean?,
}
type ClientSyncer = {
--- Applies a patch or initializes the state of the atoms with the given
--- payload from the server.
---
--- @param ...payloads The patches or hydration payloads to apply.
sync: (self: ClientSyncer, ...SyncPayload) -> (),
}
--- Creates a `ClientSyncer` object that receives patches from the server and
--- applies them to the local state.
---
--- @param options The atoms to synchronize with the server.
--- @return A `ClientSyncer` object.
local function client(options: ClientOptions): ClientSyncer
local atoms = options.atoms
local ignoreUnhydrated = options.ignoreUnhydrated ~= false
local self = {} :: ClientSyncer
local hydrated = false
local function hydrate(state: { [string | number]: any })
hydrated = true
for key, value in next, state do
local atom = atoms[key]
atom(value)
end
end
local function apply(data: { [string | number]: any })
local target = {}
for key, atom in next, atoms do
target[key] = atom()
end
target = patch.apply(target, data)
for key, atom in next, atoms do
atom(target[key])
end
end
function self:sync(...)
for index = 1, select("#", ...) do
local payload: SyncPayload = select(index, ...)
Charm.batched(function()
if payload.type == "init" then
hydrate(payload.data)
elseif not ignoreUnhydrated or hydrated then
apply(payload.data)
end
end)
end
end
return self
end
return client

View file

@ -0,0 +1,29 @@
local types = require(script.Parent.types)
type AtomMap = types.AtomMap
type NestedAtomMap = {
[string]: NestedAtomMap | () -> (),
}
local function flatten(atoms: NestedAtomMap): AtomMap
local result: AtomMap = {}
local function visit(node: NestedAtomMap, path: string)
for key, value in node do
local location = if path == "" then key else path .. "/" .. key
if type(value) == "table" then
visit(value, location)
else
result[location] = value
end
end
end
visit(atoms, "")
return result
end
return flatten

View file

@ -0,0 +1,17 @@
local client = require(script.client)
local flatten = require(script.flatten)
local patch = require(script.patch)
local server = require(script.server)
local types = require(script.types)
local validate = require(script.validate)
export type SyncPayload = types.SyncPayload
return {
client = client,
server = server,
flatten = flatten,
validate = validate,
patch = patch,
isNone = patch.isNone,
}

View file

@ -0,0 +1,28 @@
local RunService = game:GetService("RunService")
--- Schedules a function to be called at a fixed interval of `seconds`.
---
--- @param callback The function to call.
--- @param seconds The interval in seconds.
--- @return A function that cancels the interval.
local function interval(callback: () -> (), seconds: number): () -> ()
if seconds < 0 then
return function() end
end
local clock = 0
local connection = RunService.Heartbeat:Connect(function(delta)
clock += delta
if clock >= seconds then
clock = 0
callback()
end
end)
return function()
connection:Disconnect()
end
end
return interval

View file

@ -0,0 +1,109 @@
local validate = require(script.Parent.validate)
local __DEV__ = _G.__DEV__
--- A special symbol that denotes the absence of a value. Used to represent
--- deleted values in patches.
local None = { __none = "__none" }
local function isNone(value: any): boolean
return type(value) == "table" and value.__none == "__none"
end
--- JSON serialization can drop the last values of a sparse array. For example,
--- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
--- serialization of sparse arrays can be resolved by converting them to
--- dictionaries with string keys.
---
--- The `apply` function will convert these dictionaries back into arrays as
--- long as the target array is not sparse. This function ensures that the diff
--- function produces a patch that can be correctly applied.
local function stringifySparseArray(object: { any }): { [string | number]: any }
local maxn = table.maxn(object)
if maxn == 0 or maxn == #object then
-- The object is either not an array or a dense array
return object
end
local dictionary = {}
for index, value in next, object do
dictionary[tostring(index)] = value
end
return dictionary
end
--- Compares two states and returns a patch that can be applied to the previous
--- state to produce the next state.
---
--- @param prevState The previous state.
--- @param nextState The next state.
--- @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`.
--- @returns A patch that can be applied to the previous state.
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?)
serialize = serialize ~= false
local patches = table.clone(nextState)
for key, previous in next, prevState do
local current = nextState[key]
if previous == current then
patches[key] = nil
elseif current == nil then
patches[key] = None
elseif type(previous) == "table" and type(current) == "table" then
patches[key] = diff(previous, current, serialize)
end
end
-- Coerce sparse array patches into dictionaries
if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then
patches = stringifySparseArray(patches)
end
if serialize and __DEV__ then
for key, value in next, patches do
validate(value, key)
end
end
return patches
end
--- Applies a patch to a state and returns the next state.
---
--- @param state The current state.
--- @param patches The patches to apply.
--- @returns The next state.
local function apply(state: any, patches: any): any
if type(patches) == "table" and patches.__none == "__none" then
return nil
elseif type(state) ~= "table" or type(patches) ~= "table" then
return patches
end
local nextState = table.clone(state)
local stateIsArray = state[1] ~= nil
for key, patch in next, patches do
-- Diff-checking an array produces a sparse array, which will not be
-- preserved when converted to JSON. To prevent this, we turn string
-- keys back into numeric keys.
if stateIsArray and type(key) == "string" then
key = tonumber(key) or key
end
nextState[key] = apply(nextState[key], patch)
end
return nextState
end
return {
isNone = isNone,
diff = diff,
apply = apply,
}

View file

@ -0,0 +1,252 @@
local Players = game:GetService("Players")
local Charm = require(script.Parent.Parent.Charm)
local interval = require(script.Parent.interval)
local patch = require(script.Parent.patch)
local types = require(script.Parent.types)
type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload
type ServerOptions = {
--- The atoms to synchronize with the client.
atoms: AtomMap,
--- The interval at which to send patches to the client, in seconds.
--- Defaults to `0` (patches are sent up to once per frame). Set to a
--- negative value to disable automatic syncing.
interval: number?,
--- Whether the history of state changes since the client's last update
--- should be preserved. This is useful for values that change multiple times
--- per frame, where each individual change is important. Defaults to `false`.
preserveHistory: boolean?,
--- When `true`, Charm will apply validation and serialize unsafe arrays
--- to address remote event argument limitations. Defaults to `true`.
---
--- This option should be disabled if your network library uses a custom
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
autoSerialize: boolean?,
}
type ServerSyncer = {
--- Sets up a subscription to each atom that schedules a patch to be sent to
--- the client whenever the state changes. When a change occurs, the `callback`
--- is called with the player and the payload to send.
---
--- Note that the `payload` object should not be mutated. If you need to
--- modify the payload, apply the changes to a copy of the object.
---
--- @param callback The function to call when the state changes.
--- @return A cleanup function that unsubscribes all listeners.
connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (),
--- Hydrates the client's state with the server's state. This should be
--- called when a player joins the game and requires the server's state.
---
--- @param player The player to hydrate.
hydrate: (self: ServerSyncer, player: Player) -> (),
--- @deprecated For internal use only.
_sendPatch: (self: ServerSyncer, player: Player) -> (),
}
--- Creates a `ServerSyncer` object that sends patches to the client and
--- hydrates the client's state.
---
--- @param options The atoms to synchronize with the client.
--- @return A `ServerSyncer` object.
local function server(options: ServerOptions): ServerSyncer
local atoms = options.atoms
local autoSerialize = options.autoSerialize ~= false
local preserveHistory = options.preserveHistory
local syncRate = options.interval or 0
local self = {} :: ServerSyncer
local sync: (player: Player, payload: SyncPayload) -> ()
local function createSnapshot()
local snapshot = {}
for key, atom in next, atoms do
snapshot[key] = atom()
end
return snapshot
end
-- Send the initial state to a player when they join the server.
function self:hydrate(player)
assert(sync, "connect() must be called before hydrate()")
sync(player, {
type = "init",
data = createSnapshot(),
})
end
-- If the history of state changes should be preserved, keep a list of
-- previous snapshots and diff-check each one from the last.
if preserveHistory then
local snapshots = { createSnapshot() }
local changed = false
function self:connect(callback)
local subscriptions = {}
sync = callback
local function pushSnapshot(key: string | number, current: any, previous: any)
local lastSnapshot = snapshots[#snapshots]
local previousSnapshot = snapshots[#snapshots - 1]
-- Optimize snapshots by updating the most recent snapshot if the
-- previous and current values are the same, since this allows us
-- to group multiple changes into a single snapshot.
if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then
lastSnapshot[key] = current
else
local nextSnapshot = table.clone(lastSnapshot)
nextSnapshot[key] = current
table.insert(snapshots, nextSnapshot)
end
end
-- Populate the snapshot with the initial state of each atom.
-- Subscribe to each atom and update the state when it changes.
for key, atom in next, atoms do
subscriptions[key] = Charm.subscribe(atom, function(current, previous)
pushSnapshot(key, current, previous)
changed = true
end)
end
local disconnect = interval(function()
if not changed then
return
end
local payloads: { SyncPayload } = {}
local lastSnapshot
for index, snapshot in next, snapshots do
lastSnapshot = snapshot
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
end
snapshots = { lastSnapshot }
changed = false
for _, player in next, Players:GetPlayers() do
callback(player, unpack(payloads))
end
end, syncRate)
return function()
disconnect()
for _, unsubscribe in next, subscriptions do
unsubscribe()
end
end
end
function self:_sendPatch(player: Player)
assert(sync, "connect() must be called before _sendPatch()")
if not changed then
return
end
local payloads: { SyncPayload } = {}
for index, snapshot in next, snapshots do
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
end
snapshots = { snapshots[#snapshots] }
changed = false
sync(player, unpack(payloads))
end
return self
end
local previousSnapshot = createSnapshot()
local currentSnapshot = table.clone(previousSnapshot)
local changed = false
function self:connect(callback)
local subscriptions = {}
sync = callback
-- Subscribe to each atom and update the state when one changes.
for key, atom in next, atoms do
subscriptions[key] = Charm.subscribe(atom, function(state)
currentSnapshot[key] = state
changed = true
end)
end
local disconnect = interval(function()
if not changed then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
for _, player in next, Players:GetPlayers() do
callback(player, payload)
end
end, syncRate)
return function()
disconnect()
for _, unsubscribe in next, subscriptions do
unsubscribe()
end
end
end
function self:_sendPatch(player)
assert(sync, "connect() must be called before _sendPatch()")
if not changed then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
sync(player, payload)
end
return self
end
return server

View file

@ -0,0 +1,19 @@
local Charm = require(script.Parent.Parent.Charm)
type Atom<T> = Charm.Atom<T>
--- A payload that can be sent from the server to the client to synchronize
--- state between the two.
export type SyncPayload = {
type: "init",
data: { [string | number]: any },
} | {
type: "patch",
data: { [string | number]: any },
}
export type AtomMap = {
[string | number]: Atom<any>,
}
return nil

View file

@ -0,0 +1,66 @@
local SAFE_KEYS = { string = true, number = true }
local UNSAFE_VALUES = { ["function"] = true, thread = true }
local function isTableUnsafe(object: { [any]: any })
local keyType = nil
local objectSize = 0
-- All keys must have the same type
for key in next, object do
local currentType = type(key)
if not keyType and SAFE_KEYS[currentType] then
keyType = currentType
elseif keyType ~= currentType then
return true
end
objectSize += 1
end
-- If there are more keys than the length of the array, it's an array with
-- non-sequential keys.
if objectSize > #object and keyType == "number" then
return true
end
return false
end
--- Validates a value to ensure it can be synced over a remote event.
---
--- @param value The value to validate.
--- @param key The key of the value in the table.
--- @error Throws an error if the value cannot be synced.
local function validate(value: any, key: any)
local typeOfKey = type(key)
local typeOfValue = type(value)
if not SAFE_KEYS[typeOfKey] then
error(`Invalid key type '{typeOfKey}' at key '{key}'`)
elseif UNSAFE_VALUES[typeOfValue] then
error(`Invalid value type '{typeOfValue}' at key '{key}'`)
elseif typeOfValue == "table" then
if getmetatable(value) ~= nil then
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
elseif isTableUnsafe(value) then
error(
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
.. "This can be for the following reasons:\n"
.. "1. The object is an array with non-sequential keys\n"
.. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n"
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
)
end
end
if typeOfKey == "number" then
if key == math.huge or key == -math.huge then
error("Cannot sync infinity as key")
elseif key ~= math.floor(key) then
error("Cannot sync non-integer number as key")
end
end
end
return validate

View file

@ -0,0 +1,124 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local subscribe = Charm.subscribe
local client = CharmSync.client
local test = suite.test
test("should sync state payload", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = 2 },
})
assert(a() == 2, "should set a to the initial value")
end)
test("should sync state patch", function()
local a = atom({ b = 1, c = 2 })
local previous = a()
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = a() },
})
assert(a() == previous, "should set a to the initial value")
client:sync({
type = "patch",
data = { a = { b = 3 } },
})
assert(a() ~= previous, "should apply patch")
assert(a().b == 3, "should set b to the new value")
assert(a().c == 2, "should not change c")
end)
test("should fix sparse array patches", function()
local a = atom({ 1 })
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = a() },
})
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
-- was JSON-serialized and deserialized. The client should be able to catch
-- this and convert the string key back to a number.
client:sync({
type = "patch",
data = { a = { ["2"] = 2 } },
})
assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
assert(a()[2] == 2, "should convert serialized arrays to use numbers")
end)
test("should apply multiple patches", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
local calls = {}
subscribe(a, function(value)
table.insert(calls, value)
end)
client:sync({
type = "init",
data = { a = a() },
})
client:sync({
type = "patch",
data = { a = 2 },
}, {
type = "patch",
data = { a = 3 },
})
assert(a() == 3, "should apply the last patch")
assert(table.concat(calls) == "23", `wrong order: {table.concat(calls)}`)
end)
test("should batch patches", function()
local a = atom(0)
local b = atom(0)
local client = client({ atoms = { a = a, b = b } })
local states = {}
subscribe(a, function()
states.b = b()
end)
subscribe(b, function()
states.a = a()
end)
client:sync({
type = "init",
data = { a = a(), b = b() },
})
client:sync({
type = "patch",
data = { a = 1, b = 2 },
})
assert(a() == 1, "should set a to the new value")
assert(b() == 2, "should set b to the new value")
assert(states.a == 1, "sub to a should run after b updates")
assert(states.b == 2, "sub to b should run after a updates")
end)
return {}

View file

@ -0,0 +1,28 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local flatten = CharmSync.flatten
local test = suite.test
test("should flatten atoms", function()
local input = {
a = atom(0),
b = { c = atom(0) },
d = { e = { f = atom(0) } },
}
local result = flatten(input)
assert(result.a == input.a, "should flatten the first level")
assert(result["b/c"] == input.b.c, "should flatten the second level")
assert(result["d/e/f"] == input.d.e.f, "should flatten the third level")
assert(result.b == nil, "should remove the original nested tables")
assert(result.d == nil, "should remove the original nested tables")
end)
return {}

View file

@ -0,0 +1,130 @@
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
local patch = CharmSync.patch
local test = suite.test
local function serdes(value: { [any]: any })
return HttpService:JSONDecode(HttpService:JSONEncode(value))
end
test("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local patches = patch.diff(prevState, nextState)
assert(patches ~= nil, "should return a patch")
assert(patches.baz == "quux", "should return the changed value")
assert(patches.foo == nil, "should omit unchanged values")
end)
test("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local patches = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, patches)
assert(applied ~= nil, "should return a new state")
assert(applied.baz == "quux", "should apply the changed value")
assert(applied.foo == "bar", "should keep the unchanged value")
assert(applied.qaz == "qux", "should add the new value")
end)
test("allows nested patches", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local patches = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, patches)
assert(applied.foo.bar == "qux", "should apply the nested patch")
end)
test("preserves array density when applying", function()
local prevState = { "a", "b", "c", "d" }
local nextState = { ["2"] = "B", ["4"] = "D" }
local applied = patch.apply(prevState, nextState)
assert(applied[1] == "a", "should keep a")
assert(applied[2] == "B", "should apply B")
assert(applied[3] == "c", "should keep c")
assert(applied[4] == "D", "should apply D")
assert(applied["2"] == nil, "should convert serialized arrays to use numbers")
assert(applied["4"] == nil, "should convert serialized arrays to use numbers")
end)
test("preserves array density after serdes", function()
-- This particular change results in a patch that cannot be serialized
-- without losing information. Charm resolves this issue internally.
local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" }
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
for index in next, prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
for index in next, applied do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
end)
test("preserves large gaps between keys", function()
local prevState = table.create(10000, "foo")
local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" })
local start = os.clock()
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`)
for index in next, prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
end)
test("cannot preserve sparse arrays", function()
local prevState = {}
local nextState = { [2] = "foo" }
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
-- Not enough information is available to determine whether 'patches'
-- should keep the string keys added during remote event serialization.
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
assert(applied[2] == nil, "will fail to convert string keys to numbers")
end)
test("optionally fixes sparse arrays", function()
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
end
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
end
end)
test("optionally validates state", function()
local success = pcall(function()
patch.diff({}, { [0.1] = true }, true)
end)
assert(not success, "should not allow non-integer keys")
success = pcall(function()
patch.diff({}, { [0.1] = true }, false)
end)
assert(success, "should skip validation if not requested")
end)
return {}

View file

@ -0,0 +1,102 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
type SyncPayload = CharmSync.SyncPayload
local atom = Charm.atom
local server = CharmSync.server
local patch = CharmSync.patch
local test = suite.test
local target: Player = {} :: any
test("sends state to new players", function()
local a = atom(1)
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
server:hydrate(target)
assert(player == target, "should send to the target player")
assert(payload.type == "init", "should be an init payload")
assert(payload.data.a == 1, "should send the initial state")
disconnect()
end)
test("sends state patches", function()
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
a({ b = 1, c = 5 })
server:_sendPatch(target)
assert(player == target, "should send to the target player")
assert(payload.type == "patch", "should be a patch payload")
assert(payload.data.a.b == nil, "should not send unchanged values")
assert(payload.data.a.c == 5, "should send changed values")
a({ b = 1, d = 1 })
server:_sendPatch(target)
assert(payload.data.a.b == nil, "should not send unchanged values")
assert(patch.isNone(payload.data.a.c) == true, "should send None for removed values")
assert(payload.data.a.d == 1, "should send new values")
disconnect()
end)
test("preserves history", function()
local a = atom(1)
local b = atom(1)
local payloads: { SyncPayload }
local server = server({
atoms = { a = a, b = b },
interval = -1,
preserveHistory = true,
})
local disconnect = server:connect(function(_, ...)
payloads = { ... }
end)
a(2)
b(2)
server:_sendPatch(target)
assert(#payloads == 1, "should only have one payload")
assert(payloads[1].type == "patch", "should be a patch payload")
assert(payloads[1].data.a == 2, "should send the new value of a")
assert(payloads[1].data.b == 2, "should send the new value of b")
a(3)
a(4)
b(3)
server:_sendPatch(target)
assert(#payloads == 2, "should have two payloads")
assert(payloads[1].type == "patch", "1 should be a patch payload")
assert(payloads[1].data.a == 3, "1 should send the new value of a")
assert(payloads[1].data.b == nil, "1 should not send unchanged values")
assert(payloads[2].type == "patch", "2 should be a patch payload")
assert(payloads[2].data.a == 4, "2 should send the new value of a")
assert(payloads[2].data.b == 3, "2 should send the new value of b")
disconnect()
end)
return {}

View file

@ -0,0 +1,23 @@
local ServerScriptService = game:GetService("ServerScriptService")
local suite = require(ServerScriptService.Tests.suite)
suite.describe("charm-sync", function()
suite.describe("client", function()
require("./client.spec")
end)
suite.describe("server", function()
require("./server.spec")
end)
suite.describe("flatten", function()
require("./flatten.spec")
end)
suite.describe("patch", function()
require("./patch.spec")
end)
suite.describe("validate", function()
require("./validate.spec")
end)
end)
return {}

View file

@ -0,0 +1,38 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local validate = CharmSync.validate
local test = suite.test
test("validates inputs", function()
local cases: { [string]: { value: any, expected: any } } = {
array = { value = { 1, 2, 3 }, expected = true },
array_sparse = { value = { 1, nil :: any, 3 }, expected = true },
array_sparse_invalid = { value = { [1] = 0, [3] = 0 }, expected = false },
map_decimal_key = { value = { [1.5] = 0 }, expected = false },
map_infinity_key = { value = { [math.huge] = true }, expected = false },
map_mixed_key = { value = { [1] = 0, a = 0 }, expected = false },
nested_atom = { value = atom(0), expected = false },
}
for key, case in next, cases do
local success, result = pcall(function()
validate(case.value, key)
end)
if case.expected and not success then
error(`Expected {key} to be valid, threw {result}`)
elseif not case.expected and success then
error(`Expected {key} to be invalid, but it was valid`)
end
end
end)
return {}

View file

@ -0,0 +1,9 @@
[package]
name = "littensy/charm-sync"
version = "0.3.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["tests"]
[dependencies]
Charm = "littensy/charm@^0.10"

View file

@ -0,0 +1,6 @@
{
"name": "Charm",
"tree": {
"$path": "src"
}
}

View file

@ -0,0 +1,48 @@
local signals = require("./signals")
local types = require("./types")
type Atom<T> = types.Atom<T>
type Getter<T> = types.Getter<T>
type Setter<T> = types.Setter<T>
type Update<T> = types.Update<T>
local signal = signals.signal
type Equals<T> = (current: T, incoming: T) -> boolean
local function createAtom<T>(getter: Getter<T>, setter: Setter<T>): Atom<T>
local function atom(...: Update<T>): T
return if select("#", ...) == 0 then getter() else setter((...))
end
return atom
end
local function createAtomWithEqualityCheck<T>(getter: Getter<T>, setter: Setter<T>, equals: Equals<T>): Atom<T>
local function atom(...: Update<T>): T
if select("#", ...) == 0 then
return getter()
end
local nextState = (...)
return setter(function(currentState): T
local nextState = if type(nextState) == "function" then nextState(currentState) else nextState
return if equals(currentState, nextState) then currentState else nextState
end)
end
return atom
end
local function atom<T>(state: T, equals: Equals<T>?): Atom<T>
local getter, setter = signal(state)
if not equals then
return createAtom(getter, setter)
else
return createAtomWithEqualityCheck(getter, setter, equals)
end
end
return atom

View file

@ -0,0 +1,22 @@
local signals = require("./signals")
type ReactiveNode = signals.ReactiveNode
local startBatch = signals.startBatch
local endBatch = signals.endBatch
local function handleBatched(ok: boolean, ...)
endBatch()
if ok then
return ...
else
error(...)
end
end
local function batched<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
startBatch()
return handleBatched(pcall(callback, ...))
end
return batched

View file

@ -0,0 +1,26 @@
local signals = require("./signals")
local onEffectCleanup = signals.onEffectCleanup
local function effect(callback: (dispose: () -> ()) -> ...(() -> ())?): () -> ()
local disposed = false
local function dispose()
disposed = true
end
dispose = signals.effect(function()
local cleanup = callback(dispose)
if cleanup then
onEffectCleanup(cleanup)
end
end)
if disposed then
dispose()
end
return dispose
end
return effect

View file

@ -0,0 +1,14 @@
local signals = require("./signals")
local onScopeDispose = signals.onScopeDispose
local function effectScope(callback: () -> ...(() -> ())?, detached: boolean?): () -> ()
return signals.effectScope(function()
local cleanup = callback()
if cleanup then
onScopeDispose(cleanup)
end
end, detached)
end
return effectScope

View file

@ -0,0 +1,35 @@
local atom = require("@self/atom")
local batched = require("@self/batched")
local effect = require("@self/effect")
local listen = require("@self/listen")
local mapped = require("@self/mapped")
local observe = require("@self/observe")
local onCleanup = require("@self/onCleanup")
local signals = require("@self/signals")
local subscribe = require("@self/subscribe")
local types = require("@self/types")
local untracked = require("@self/untracked")
export type Atom<T = any> = types.Atom<T>
export type Getter<T = any> = types.Getter<T>
export type Selector<T = any> = types.Selector<T>
export type Setter<T = any> = types.Setter<T>
export type Update<T = any> = types.Update<T>
local computed = signals.computed
local effectScope = signals.effectScope
return {
atom = atom,
batched = batched,
computed = computed,
effect = effect,
effectScope = effectScope,
listen = listen,
mapped = mapped,
observe = observe,
subscribe = subscribe,
untracked = untracked,
onCleanup = onCleanup,
signals = signals,
}

View file

@ -0,0 +1,38 @@
local signals = require("./signals")
local computed = signals.computed
local effect = signals.effect
local effectScope = signals.effectScope
local onEffectCleanup = signals.onEffectCleanup
local function listen<T>(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> ()
local tracked = computed(function()
return selector()
end)
local current: T
local disposed = false
local function dispose()
disposed = true
end
dispose = effect(function()
local previous = current
current = tracked()
local stopScope = effectScope(function()
listener(current, previous, dispose)
end)
onEffectCleanup(stopScope)
end)
if disposed then
dispose()
end
return dispose
end
return listen

View file

@ -0,0 +1,72 @@
local noYield = require("./noYield")
local signals = require("./signals")
local computed = signals.computed
local setCurrentSub = signals.setCurrentSub
type Mapped =
& (<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO })
& (<K, VI, VO>(selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO })
local function ensureCanMutateOutput<KO, VO>(output: { [KO]: VO }, previous: { [KO]: VO }?): { [KO]: VO }
return if output == previous then table.clone(output) else output
end
local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO }
local currentData: { [KI]: VI } = {}
local keyCache: { [KI]: KO } = {}
local function map(value: VI, key: KI): (VO, KO)
local currentSub = setCurrentSub(nil)
local success, valueOut, keyOut = noYield(mapper, value, key)
setCurrentSub(currentSub)
if not success then
error(`mapped() transform function failed: {valueOut}`)
end
if keyOut == nil then
keyOut = key :: any
end
return valueOut, keyOut
end
return computed(function(previousOutput)
local currentOutput: { [KO]: VO } = previousOutput or {}
local previousData = currentData
currentData = selector()
for key in previousData do
if currentData[key] ~= nil then
continue
end
local cachedKey = keyCache[key]
if cachedKey then
currentOutput = ensureCanMutateOutput(currentOutput, previousOutput)
currentOutput[cachedKey] = nil
keyCache[key] = nil
end
end
for key, value in currentData do
if value == previousData[key] then
continue
end
local valueOut, keyOut = map(value, key)
if currentOutput[keyOut] ~= valueOut then
currentOutput = ensureCanMutateOutput(currentOutput, previousOutput)
currentOutput[keyOut] = valueOut
keyCache[key] = keyOut
end
end
return currentOutput
end)
end
return mapped :: Mapped

View file

@ -0,0 +1,20 @@
local function handleNoYield(thread: thread, ok: boolean, ...)
if not ok then
return false, debug.traceback(thread, tostring((...)))
end
if coroutine.status(thread) ~= "dead" then
return false, debug.traceback(thread, "Attempted to yield!")
end
return true, ...
end
local function noYield<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...)
local thread = coroutine.create(callback)
return handleNoYield(thread, coroutine.resume(thread, ...))
end
-- To eliminate the overhead of noYield returning callback(...) in production,
-- replace it with pcall to simulate the same behavior.
return if _G.__DEV__ then noYield else pcall :: never

View file

@ -0,0 +1,56 @@
local onCleanup = require("./onCleanup")
local signals = require("./signals")
local effect = signals.effect
local effectScope = signals.effectScope
local onScopeDispose = signals.onScopeDispose
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> ()
local scopes: { [K]: () -> () } = {}
local currentItems: { [K]: V } = {}
local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V })
for key in previousItems do
if currentItems[key] == nil and scopes[key] then
scopes[key]()
scopes[key] = nil
end
end
for key, value in currentItems do
if previousItems[key] == nil then
scopes[key] = effectScope(function()
local cleanup = observer(value, key)
if cleanup then
onScopeDispose(cleanup)
end
end, true)
end
end
end
local function disposeScopes()
for _, dispose in scopes do
dispose()
end
table.clear(scopes)
end
local disposeEffect = effect(function()
local previousItems = currentItems
currentItems = selector()
if currentItems ~= previousItems then
updateScopes(currentItems, previousItems)
end
end)
onCleanup(disposeScopes, true)
return function()
disposeEffect()
disposeScopes()
end
end
return observe

View file

@ -0,0 +1,20 @@
local signals = require("./signals")
local onEffectCleanup = signals.onEffectCleanup
local onScopeDispose = signals.onScopeDispose
local getCurrentScope = signals.getCurrentScope
local getCurrentSub = signals.getCurrentSub
local warn = warn or error :: never
local function onCleanup(cleanup: () -> (), failSilently: boolean?)
if getCurrentSub() then
onEffectCleanup(cleanup)
elseif getCurrentScope() then
onScopeDispose(cleanup)
elseif _G.__DEV__ and not failSilently then
warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2))
end
end
return onCleanup

View file

@ -0,0 +1,430 @@
--[[
alien-signals 2.0.1 implementation adopted from
https://github.com/Nicell/alien-signals-luau
https://github.com/stackblitz/alien-signals
MIT License
Copyright (c) 2024-present Johnson Chu, Nicholas Winans
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]
local noYield = require("./noYield")
local system = require("./system")
export type ReactiveFlags = system.ReactiveFlags
export type ReactiveNode = system.ReactiveNode
export type Link = system.Link
local ReactiveFlags = system.ReactiveFlags
local createReactiveSystem = system.createReactiveSystem
local NONE = 0b0000000
local MUTABLE = 0b0000001
local WATCHING = 0b0000010
local DIRTY = 0b0010000
local PENDING = 0b0100000
local QUEUED = 0b1000000
type EffectScope = ReactiveNode & {
cleanups: { () -> () },
}
type Effect = ReactiveNode & {
fn: () -> (),
cleanups: { () -> () },
}
type Computed<T = any> = ReactiveNode & {
value: T?,
getter: (previousValue: T?) -> T,
}
type Signal<T = any> = ReactiveNode & {
previousValue: T,
value: T,
}
local pauseStack: { ReactiveNode? } = {}
local queuedEffects: { Effect | EffectScope? } = {}
local link: (dep: ReactiveNode, sub: ReactiveNode) -> ()
local unlink: (link: Link, sub: ReactiveNode?) -> Link?
local propagate: (link: Link) -> ()
local checkDirty: (link: Link, sub: ReactiveNode) -> boolean
local endTracking: (sub: ReactiveNode) -> ()
local startTracking: (sub: ReactiveNode) -> ()
local shallowPropagate: (link: Link) -> ()
local batchDepth = 0
local notifyIndex = 0
local queuedEffectsLength = 0
local activeSub: ReactiveNode?
local activeScope: Effect | EffectScope?
local function getCurrentSub(): ReactiveNode?
return activeSub
end
local function setCurrentSub(sub: ReactiveNode?)
local prevSub = activeSub
activeSub = sub
return prevSub
end
local function getCurrentScope(): Effect | EffectScope?
return activeScope
end
local function setCurrentScope(scope: Effect | EffectScope?)
local prevScope = activeScope
activeScope = scope
return prevScope
end
local function onEffectCleanup(cleanup: () -> ())
if activeSub then
table.insert((activeSub :: Effect).cleanups, cleanup)
end
end
local function onScopeDispose(cleanup: () -> ())
if activeScope then
table.insert(activeScope.cleanups, cleanup)
end
end
local function cleanupEffect(e: Effect | EffectScope)
for _, cleanup in e.cleanups :: { () -> () } do
local prevSub = setCurrentSub(nil)
local success, result: unknown = noYield(cleanup)
setCurrentSub(prevSub)
if not success then
error(result)
end
end
table.clear(e.cleanups)
end
local function run(e: Effect | EffectScope, flags: ReactiveFlags): ()
if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(e.deps :: Link, e)) then
cleanupEffect(e)
local prev = setCurrentSub(e)
startTracking(e)
local success, result: unknown = noYield((e :: Effect).fn)
setCurrentSub(prev)
endTracking(e)
if not success then
error(result)
end
return
elseif bit32.band(flags, PENDING) ~= 0 then
(e :: Effect).flags = bit32.band(flags, bit32.bnot(PENDING))
end
local link = e.deps
while link do
local dep = link.dep
local depFlags = dep.flags
if bit32.band(depFlags, QUEUED) ~= 0 then
depFlags = bit32.band(depFlags, bit32.bnot(QUEUED))
dep.flags = depFlags
run(dep :: Effect, depFlags)
end
link = link.nextDep
end
end
local function flush(): ()
while notifyIndex < queuedEffectsLength do
notifyIndex += 1
local effect = queuedEffects[notifyIndex] :: Effect
queuedEffects[notifyIndex] = nil
effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED))
run(effect, effect.flags)
end
notifyIndex = 0
queuedEffectsLength = 0
end
local function getBatchDepth(): number
return batchDepth
end
local function startBatch()
batchDepth += 1
end
local function endBatch()
batchDepth -= 1
if batchDepth == 0 then
flush()
end
end
--- @deprecated Will be removed in the next major version. Use
--- `const pausedSub = setCurrentSub(undefined)` instead for better performance.
local function pauseTracking()
table.insert(pauseStack, setCurrentSub(nil))
end
--- @deprecated Will be removed in the next major version. Use
--- `setCurrentSub(pausedSub)` instead for better performance.
local function resumeTracking()
setCurrentSub(table.remove(pauseStack))
end
local function updateComputed(c: Computed): boolean
local prevSub = setCurrentSub(c)
startTracking(c)
local oldValue = c.value
local success, result = noYield(c.getter, oldValue)
setCurrentSub(prevSub)
endTracking(c)
if not success then
error(result)
end
if oldValue ~= result then
c.value = result
return true
else
return false
end
end
local function updateSignal(s: Signal, value: any): boolean
s.flags = MUTABLE
if s.previousValue ~= value then
s.previousValue = value
return true
else
return false
end
end
local function signalGetterSetter<T>(this: Signal<T>): (() -> T, (value: ((T) -> T) | T) -> T)
local function getter(): T
local value = this.value
if bit32.band(this.flags, DIRTY) ~= 0 then
if updateSignal(this, value) then
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
end
if activeSub then
link(this, activeSub)
end
return value
end
local function setter(newValue: ((T) -> T) | T): T
local value = this.value
local newValue = newValue :: T
if type(newValue) == "function" then
local prev = setCurrentSub(nil)
local success, result = noYield(newValue, value)
setCurrentSub(prev)
if not success then
error(result)
end
newValue = result
end
if value ~= newValue then
this.value = newValue
this.flags = bit32.bor(MUTABLE, DIRTY)
local subs = this.subs
if subs then
propagate(subs)
if batchDepth == 0 then
flush()
end
end
end
return newValue
end
return getter, setter
end
local function computedGetter<T>(this: Computed<T>): () -> T
return function()
local flags = this.flags
if
bit32.band(flags, DIRTY) ~= 0
or (bit32.band(flags, PENDING) ~= 0 and checkDirty(this.deps :: Link, this))
then
if updateComputed(this) then
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
elseif bit32.band(flags, PENDING) ~= 0 then
this.flags = bit32.band(flags, bit32.bnot(PENDING))
end
if activeSub then
link(this, activeSub)
elseif activeScope then
link(this, activeScope)
end
return this.value :: T
end
end
local function effectStop(this: Effect | EffectScope): () -> ()
return function()
local dep = this.deps
while dep do
dep = unlink(dep, this)
end
local sub = this.subs
if sub then
unlink(sub)
end
(this :: Effect).flags = NONE
cleanupEffect(this)
end
end
local function signal<T>(initialValue: T): (() -> T, (value: ((T) -> T) | T) -> T)
return signalGetterSetter({
previousValue = initialValue,
value = initialValue,
flags = MUTABLE,
})
end
local function computed<T>(getter: (previousValue: T?) -> T): () -> T
return computedGetter({
flags = bit32.bor(MUTABLE, DIRTY),
getter = getter,
})
end
local function effect(fn: () -> ()): () -> ()
local e: Effect = {
fn = fn,
cleanups = {},
flags = WATCHING,
}
if activeSub then
link(e, activeSub)
elseif activeScope then
link(e, activeScope)
end
local prev = setCurrentSub(e)
local success, result: unknown = noYield(fn)
setCurrentSub(prev)
if not success then
error(result)
end
return effectStop(e)
end
local function effectScope(fn: () -> (), detached: boolean?): () -> ()
local e: EffectScope = {
cleanups = {},
flags = NONE,
}
if activeScope and not detached then
link(e, activeScope)
end
local prevSub = setCurrentSub(nil)
local prevScope = setCurrentScope(e)
local success, result: unknown = noYield(fn)
setCurrentScope(prevScope)
setCurrentSub(prevSub)
if not success then
error(result)
end
return effectStop(e)
end
local function update(signal: Signal | Computed): boolean
if (signal :: Computed).getter then
return updateComputed(signal :: Computed)
else
return updateSignal(signal :: Signal, signal.value)
end
end
local function notify(e: Effect | EffectScope)
local flags = e.flags
if bit32.band(flags, QUEUED) == 0 then
(e :: Effect).flags = bit32.bor(flags, QUEUED)
local subs = e.subs
if subs then
notify(subs.sub :: Effect)
else
queuedEffectsLength += 1
queuedEffects[queuedEffectsLength] = e
end
end
end
local function unwatched(node: Signal | Computed | Effect | EffectScope)
if (node :: Computed).getter then
local toRemove = node.deps
if toRemove then
(node :: Computed).flags = bit32.bor(MUTABLE, DIRTY)
repeat
toRemove = unlink(toRemove, node)
until not toRemove
end
elseif (node :: Effect).cleanups ~= nil then
effectStop(node :: Effect)()
end
end
local reactiveSystem = createReactiveSystem({
update = update :: (ReactiveNode) -> boolean,
notify = notify :: (ReactiveNode) -> (),
unwatched = unwatched :: (ReactiveNode) -> (),
})
link = reactiveSystem.link
unlink = reactiveSystem.unlink
propagate = reactiveSystem.propagate
checkDirty = reactiveSystem.checkDirty
endTracking = reactiveSystem.endTracking
startTracking = reactiveSystem.startTracking
shallowPropagate = reactiveSystem.shallowPropagate
return {
ReactiveFlags = ReactiveFlags,
createReactiveSystem = createReactiveSystem,
getCurrentSub = getCurrentSub,
setCurrentSub = setCurrentSub,
getCurrentScope = getCurrentScope,
setCurrentScope = setCurrentScope,
getBatchDepth = getBatchDepth,
startBatch = startBatch,
endBatch = endBatch,
pauseTracking = pauseTracking,
resumeTracking = resumeTracking,
onEffectCleanup = onEffectCleanup,
onScopeDispose = onScopeDispose,
signal = signal,
computed = computed,
effect = effect,
effectScope = effectScope,
}

View file

@ -0,0 +1,33 @@
local signals = require("./signals")
local computed = signals.computed
local effect = signals.effect
local effectScope = signals.effectScope
local onEffectCleanup = signals.onEffectCleanup
local function subscribe<T>(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> ()
local tracked = computed(function()
return selector()
end)
local current: T
local mounted = true
return effect(function()
local previous = current
current = tracked()
if mounted then
mounted = false
return
end
local stopScope = effectScope(function()
listener(current, previous)
end)
onEffectCleanup(stopScope)
end)
end
return subscribe

View file

@ -0,0 +1,357 @@
--[[
alien-signals 2.0.1 implementation adopted from
https://github.com/Nicell/alien-signals-luau
https://github.com/stackblitz/alien-signals
MIT License
Copyright (c) 2024-present Johnson Chu, Nicholas Winans
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]
export type ReactiveNode = {
deps: Link?,
depsTail: Link?,
subs: Link?,
subsTail: Link?,
flags: ReactiveFlags,
}
export type Link = {
dep: ReactiveNode,
sub: ReactiveNode,
prevSub: Link?,
nextSub: Link?,
prevDep: Link?,
nextDep: Link?,
}
type Stack<T> = {
value: T,
prev: Stack<T>?,
}
export type ReactiveFlags = number
local NONE = 0b0000000
local MUTABLE = 0b0000001
local WATCHING = 0b0000010
local RECURSED_CHECK = 0b0000100
local RECURSED = 0b0001000
local DIRTY = 0b0010000
local PENDING = 0b0100000
local ReactiveFlags = {
None = NONE,
Mutable = MUTABLE,
Watching = WATCHING,
RecursedCheck = RECURSED_CHECK,
Recursed = RECURSED,
Dirty = DIRTY,
Pending = PENDING,
}
local function createReactiveSystem(props: {
update: (sub: ReactiveNode) -> boolean,
notify: (sub: ReactiveNode) -> (),
unwatched: (sub: ReactiveNode) -> (),
})
local update = props.update
local notify = props.notify
local unwatched = props.unwatched
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
local depsTail = sub.depsTail
if depsTail then
local link = sub.deps :: Link
repeat
if link == checkLink then
return true
end
if link == depsTail then
break
end
link = link.nextDep :: Link
until not link
end
return false
end
local function link(dep: ReactiveNode, sub: ReactiveNode): ()
local prevDep = sub.depsTail
if prevDep and prevDep.dep == dep then
return
end
local nextDep: Link?
local recursedCheck = bit32.band(sub.flags, RECURSED_CHECK) ~= 0
if recursedCheck then
nextDep = if prevDep then prevDep.nextDep else sub.deps
if nextDep and nextDep.dep == dep then
sub.depsTail = nextDep
return
end
end
local prevSub = dep.subsTail
if prevSub and prevSub.sub == sub and (not recursedCheck or isValidLink(prevSub, sub)) then
return
end
local newLink: Link = {
dep = dep,
sub = sub,
prevDep = prevDep,
nextDep = nextDep,
prevSub = prevSub,
}
sub.depsTail = newLink
dep.subsTail = newLink
if nextDep then
nextDep.prevDep = newLink
end
if prevDep then
prevDep.nextDep = newLink
else
sub.deps = newLink
end
if prevSub then
prevSub.nextSub = newLink
else
dep.subs = newLink
end
end
local function unlink(link: Link, sub: ReactiveNode?): Link?
local sub = sub or link.sub
local dep = link.dep
local prevDep = link.prevDep
local nextDep = link.nextDep
local nextSub = link.nextSub
local prevSub = link.prevSub
if nextDep then
nextDep.prevDep = prevDep
else
sub.depsTail = prevDep
end
if prevDep then
prevDep.nextDep = nextDep
else
sub.deps = nextDep
end
if nextSub then
nextSub.prevSub = prevSub
else
dep.subsTail = prevSub
end
if prevSub then
prevSub.nextSub = nextSub
else
dep.subs = nextSub
if not nextSub then
unwatched(dep)
end
end
return nextDep
end
local function propagate(link: Link): ()
local next = link.nextSub
local stack: Stack<Link?>?
repeat
local sub = link.sub
local flags = sub.flags
if bit32.band(flags, bit32.bor(MUTABLE, WATCHING)) ~= 0 then
if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
sub.flags = bit32.bor(flags, PENDING)
elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then
flags = NONE
elseif bit32.band(flags, RECURSED_CHECK) == 0 then
sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING)
elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then
sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING))
flags = bit32.band(flags, MUTABLE)
else
-- selene: allow(if_same_then_else)
flags = NONE
end
if bit32.band(flags, WATCHING) ~= 0 then
notify(sub)
end
if bit32.band(flags, MUTABLE) ~= 0 then
local subSubs = sub.subs
if subSubs then
link = subSubs
if subSubs.nextSub then
stack = { value = next, prev = stack }
next = link.nextSub
end
continue
end
end
end
link = next :: Link
if link then
next = link.nextSub
continue
end
local continueTop = false
while stack do
link = stack.value :: Link
stack = stack.prev
if link then
next = link.nextSub
continueTop = true
break
end
end
if continueTop then
continue
end
break
until false
end
local function startTracking(sub: ReactiveNode): ()
sub.depsTail = nil
sub.flags = bit32.bor(bit32.band(sub.flags, bit32.bnot(bit32.bor(RECURSED, DIRTY, PENDING))), RECURSED_CHECK)
end
local function endTracking(sub: ReactiveNode): ()
local depsTail = sub.depsTail
local toRemove = if depsTail then depsTail.nextDep else sub.deps
while toRemove do
toRemove = unlink(toRemove, sub)
end
sub.flags = bit32.band(sub.flags, bit32.bnot(RECURSED_CHECK))
end
local function shallowPropagate(link: Link): ()
repeat
local sub = link.sub
local subFlags = sub.flags
if bit32.band(subFlags, bit32.bor(PENDING, DIRTY)) == PENDING then
sub.flags = bit32.bor(subFlags, DIRTY)
if bit32.band(subFlags, WATCHING) ~= 0 then
notify(sub)
end
end
link = link.nextSub :: Link
until not link
end
local function checkDirty(link: Link, sub: ReactiveNode): boolean
local stack: Stack<Link>?
local checkDepth = 0
repeat
local dep = link.dep
local depFlags = dep.flags
local dirty = false
if bit32.band(sub.flags, DIRTY) ~= 0 then
dirty = true
elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
if update(dep) then
local subs = dep.subs :: Link
if subs.nextSub then
shallowPropagate(subs)
end
dirty = true
end
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
if link.nextSub or link.prevSub then
stack = { value = link, prev = stack }
end
link = dep.deps :: Link
sub = dep
checkDepth += 1
continue
end
if not dirty and link.nextDep then
link = link.nextDep
continue
end
local continueTop = false
while checkDepth ~= 0 do
checkDepth -= 1
local firstSub = sub.subs :: Link
local hasMultipleSubs = firstSub.nextSub ~= nil
if hasMultipleSubs then
link = (stack :: Stack<Link>).value
stack = (stack :: Stack<Link>).prev
else
link = firstSub
end
if dirty then
if update(sub) then
if hasMultipleSubs then
shallowPropagate(firstSub)
end
sub = link.sub
continue
end
else
sub.flags = bit32.band(sub.flags, bit32.bnot(PENDING))
end
sub = link.sub
if link.nextDep then
link = link.nextDep
continueTop = true
break
end
dirty = false
end
if continueTop then
continue
end
return dirty
until false
end
return {
link = link,
unlink = unlink,
propagate = propagate,
checkDirty = checkDirty,
endTracking = endTracking,
startTracking = startTracking,
shallowPropagate = shallowPropagate,
}
end
return {
ReactiveFlags = ReactiveFlags,
createReactiveSystem = createReactiveSystem,
}

View file

@ -0,0 +1,11 @@
export type Getter<T = any> = () -> T
export type Setter<T = any> = (update: Update<T>) -> T
export type Update<T = any> = T | (previous: T) -> T
export type Selector<T = any> = () -> T
export type Atom<T = any> = (...Update<T>) -> T
return {}

View file

@ -0,0 +1,21 @@
local noYield = require("./noYield")
local signals = require("./signals")
type ReactiveNode = signals.ReactiveNode
local setCurrentSub = signals.setCurrentSub
local function handleUntracked(currentSub: ReactiveNode?, ok: boolean, ...)
setCurrentSub(currentSub)
if ok then
return ...
else
error(...)
end
end
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
return handleUntracked(setCurrentSub(nil), noYield(callback, ...))
end
return untracked

View file

@ -0,0 +1,101 @@
local Charm = require("../src")
local suite = require("@test/suite")
local atom = Charm.atom
local listen = Charm.listen
local effect = Charm.effect
local test = suite.test
test("should listen to selector", function()
local source = atom(0)
local current, previous
local unsub = listen(function()
return source() + 1
end, function(a, b)
current = a
previous = b
end)
assert(current == 1 and previous == nil, "should run initially")
source(0)
assert(current == 1 and previous == nil, "should skip redundant update")
source(1)
assert(current == 2 and previous == 1, "should run with new value")
unsub()
source(2)
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should run in untracked", function()
local runs = 0
local cleanups = 0
local a = atom(0)
local b = atom(0)
listen(a, function()
runs += 1
b()
effect(function()
return function()
cleanups += 1
end
end)
end)
assert(runs == 1, "should run once on init")
assert(cleanups == 0, "should not cleanup yet")
a(1)
assert(runs == 2, "should run on a change")
assert(cleanups == 1, "should cleanup first call")
b(1)
assert(runs == 2, "should not run on b change")
assert(cleanups == 1, "should not cleanup second call")
end)
test("should run if first value is nil", function()
local runs = 0
local source = atom(nil :: number?)
listen(source, function()
runs += 1
end)
assert(runs == 1, "should run once on init")
source(1)
assert(runs == 2, "should run once on change")
end)
test("should track inner subs", function()
local order = {}
local a = atom(1)
local unsub = listen(a, function()
table.insert(order, 1)
listen(a, function()
table.insert(order, 2)
end)
listen(a, function()
table.insert(order, 3)
end)
end)
assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`)
table.clear(order)
a(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
a(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
unsub()
a(a() + 1)
assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
end)
return {}

View file

@ -0,0 +1,108 @@
local Charm = require("../src")
local suite = require("@test/suite")
local atom = Charm.atom
local mapped = Charm.mapped
local test = suite.test
test("should map atom", function()
local src = atom({ 1, 2, 3 })
local map = mapped(src, function(value: number)
return value + 1
end)
assert(table.concat(map()) == "234", table.concat(map()))
src({ 4, 5, 6 })
assert(table.concat(map()) == "567", table.concat(map()))
end)
test("should not map unchanged values", function()
local calls = 0
local src = atom({ 1, 2, 3 })
local map = mapped(src, function(value: number)
calls += 1
return value + 1
end)
assert(table.concat(map()) == "234", table.concat(map()))
assert(calls == 3, "should map 3 values")
src({ 1, 3, 3 })
assert(table.concat(map()) == "244", table.concat(map()))
assert(calls == 4, "should map 1 change")
src({})
assert(#map() == 0, "should be empty")
assert(calls == 4, "should not map for removed values")
end)
test("should not update if no values change", function()
local calls = 0
local src = atom({ 1, 2, 3 })
local map = mapped(src, function(value: number)
calls += 1
return value + 1
end)
local prev = map()
assert(calls == 3, "should map 3 values")
assert(table.concat(map()) == "234", table.concat(map()))
src({ 4, 5, 6, 7 })
src({ 1, 2, 3 })
map()
assert(calls == 3, "should not map unchanged values")
assert(table.concat(map()) == "234", table.concat(map()))
assert(prev == map(), "should not create new table")
end)
test("should filter values", function()
local calls = 0
local src = atom({ a = 1, b = 2, c = 3 })
local map = mapped(src, function(value: number)
calls += 1
return if value < 0 then nil else value
end)
local state = map()
assert(calls == 3, "should map 3 values")
assert(state.a and state.b and state.c, "should map all values")
calls = 0
src({ a = -1, b = 2, c = 3, d = 4 })
state = map()
assert(calls == 2, "should map 2 values")
assert(state.a == nil, "should remove a")
assert(state.b and state.c, "should keep b and c")
assert(state.d == 4, "should add d")
end)
test("should map keys", function()
local calls = 0
local src = atom({ a = 1, b = 2, c = 3 })
local map = mapped(src, function(value: number, key: string)
calls += 1
return if value < 0 then nil else value, string.upper(key)
end)
local state = map()
assert(calls == 3, "should map 3 values")
assert(not (state.a and state.b and state.c), "should discard old keys")
assert(state.A == 1, "should map a")
assert(state.B == 2, "should map b")
assert(state.C == 3, "should map c")
calls = 0
src({ a = -1, b = 2, c = 3, d = 4 })
state = map()
assert(calls == 2, "should map 2 values")
assert(state.A == nil and state.a == nil, "should remove a")
assert(state.B and state.C, "should keep b and c")
assert(state.D == 4, "should add d")
end)
return {}

View file

@ -0,0 +1,64 @@
local Charm = require("../src")
local suite = require("@test/suite")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local observe = Charm.observe
local test = suite.test
test("should observe items", function()
local items = atom({ 0, 0, 0 })
local updates = {}
local cleanups = {}
observe(items, function(_, key)
local get = computed(function()
return items()[key]
end)
effect(function()
table.insert(updates, get())
end)
return function()
table.insert(cleanups, key)
end
end)
assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`)
table.clear(updates)
items({ 1, 2, 3 })
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
table.clear(updates)
items({ 1, 2, nil :: any, 4 })
assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
end)
test("should dispose scopes", function()
local items = atom({ 1, 2, 3 })
local a = atom(0)
local runs = {}
local dispose = observe(items, function(item)
effect(function()
table.insert(runs, item)
a()
end)
end)
table.clear(runs)
items({ 1, 2 })
a(1)
assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`)
table.clear(runs)
dispose()
a(2)
assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
end)
return {}

View file

@ -0,0 +1,83 @@
local signals = require("../../src/signals")
local suite = require("@test/suite")
local computed = signals.computed
local signal = signals.signal
local test = suite.test
test("should correctly propagate changes through computed signals", function()
local src, setSrc = signal(0)
local c1 = computed(function()
return src() % 2
end)
local c2 = computed(function()
return c1()
end)
local c3 = computed(function()
return c2()
end)
c3()
setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
c2() -- c1 -> none, c2 -> none
setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty
assert(c3() == 1, "bad final value")
end)
test("should propagate updated source value through chained computations", function()
local src, setSrc = signal(0)
local a = computed(function()
return src()
end)
local b = computed(function()
return a() % 2
end)
local c = computed(function()
return src()
end)
local d = computed(function()
return b() + c()
end)
assert(d() == 0, "bad initial value")
setSrc(2)
assert(d() == 2, "bad updated value")
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a, setA = signal(false)
local b = computed(function()
return a()
end)
local c = computed(function()
b()
return 0
end)
local d = computed(function()
c()
return b()
end)
assert(d() == false, "bad initial value")
setA(true)
assert(d() == true, "bad updated value")
end)
test("should not update if the signal value is reverted", function()
local times = 0
local src, setSrc = signal(0)
local c1 = computed(function()
times += 1
return src()
end)
c1()
assert(times == 1, "computed did not run")
setSrc(1)
setSrc(0)
c1()
assert(times == 1, "computed ran when it should not")
end)
return {}

View file

@ -0,0 +1,359 @@
local signals = require("../../src/signals")
local suite = require("@test/suite")
local computed = signals.computed
local effect = signals.effect
local effectScope = signals.effectScope
local endBatch = signals.endBatch
local setCurrentSub = signals.setCurrentSub
local onEffectCleanup = signals.onEffectCleanup
local signal = signals.signal
local startBatch = signals.startBatch
local test = suite.test
test("should clear subscriptions when untracked by all subscribers", function()
local bRunTimes = 0
local a, setA = signal(1)
local b = computed(function()
bRunTimes += 1
return a() * 2
end)
local stopEffect = effect(function()
b()
end)
assert(bRunTimes == 1, "should run initially")
setA(2)
assert(bRunTimes == 2, "should run after change")
stopEffect()
setA(3)
assert(bRunTimes == 2, "should not run after stop")
end)
test("should not run untracked inner effect", function()
local a, setA = signal(3)
local b = computed(function()
return a() > 0
end)
effect(function()
if b() then
effect(function()
if a() == 0 then
error("bad")
end
end)
end
end)
setA(2)
setA(1)
setA(0)
end)
test("should run outer effect first", function()
local a, setA = signal(1)
local b, setB = signal(1)
effect(function()
if a() > 0 then
effect(function()
b()
if a() == 0 then
error("bad")
end
end)
end
end)
startBatch()
setB(0)
setA(0)
endBatch()
end)
test("should not trigger inner effect when resolve maybe dirty", function()
local a, setA = signal(0)
local b = computed(function()
return a() % 2
end)
local innerTriggerTimes = 0
effect(function()
effect(function()
b()
innerTriggerTimes += 1
if innerTriggerTimes >= 2 then
error("bad")
end
end)
end)
setA(2)
end)
test("should trigger inner effects in sequence", function()
local a, setA = signal(0)
local b, setB = signal(0)
local c = computed(function()
return a() - b()
end)
local order: { string } = {}
effect(function()
c()
effect(function()
table.insert(order, "first inner")
a()
end)
effect(function()
table.insert(order, "last inner")
a()
b()
end)
end)
table.clear(order)
startBatch()
setB(1)
setA(1)
endBatch()
assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`)
end)
test("should trigger inner effects in sequence in effect scope", function()
local a, setA = signal(0)
local b, setB = signal(0)
local order: { string } = {}
effectScope(function()
effect(function()
table.insert(order, "first inner")
a()
end)
effect(function()
table.insert(order, "last inner")
a()
b()
end)
end)
table.clear(order)
startBatch()
setB(1)
setA(1)
endBatch()
assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`)
end)
test("should custom effect support batch", function()
local function batchEffect(fn: () -> ())
return effect(function()
startBatch()
local success, result: unknown = pcall(fn)
endBatch()
if not success then
error(result)
end
end)
end
local logs: { string } = {}
local a = signal(0)
local b, setB = signal(0)
local aa = computed(function()
table.insert(logs, "aa-0")
if a() == 0 then
setB(1)
end
table.insert(logs, "aa-1")
return nil
end)
local bb = computed(function()
table.insert(logs, "bb")
return b()
end)
batchEffect(function()
bb()
end)
batchEffect(function()
aa()
end)
assert(table.concat(logs, ", ") == "bb, aa-0, aa-1, bb", `wrong order: {table.concat(logs, ", ")}`)
end)
test("should duplicate subscribers do not affect the notify order", function()
local src1, setSrc1 = signal(0)
local src2, setSrc2 = signal(0)
local order: { string } = {}
effect(function()
table.insert(order, "a")
local currentSub = setCurrentSub(nil)
local isOne = src2() == 1
setCurrentSub(currentSub)
if isOne then
src1()
end
src2()
src1()
end)
effect(function()
table.insert(order, "b")
src1()
end)
setSrc2(1) -- src1.subs: a -> b -> a
table.clear(order)
setSrc1(src1() + 1)
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
end)
test("should handle side effect with inner effects", function()
local a, setA = signal(0)
local b, setB = signal(0)
local order: { string } = {}
effect(function()
effect(function()
a()
table.insert(order, "a")
end)
effect(function()
b()
table.insert(order, "b")
end)
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
table.clear(order)
setB(1)
setA(1)
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end)
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a, setA = signal(false)
local b = computed(function()
return a()
end)
local c = computed(function()
b()
return 0
end)
local d = computed(function()
c()
return b()
end)
local triggers = 0
effect(function()
d()
triggers += 1
end)
assert(triggers == 1, "did not run initially")
setA(true)
assert(triggers == 2, "did not update indirectly")
end)
test("should run cleanups in sequence", function()
local order = {}
local a, setA = signal(false)
local stopEffect = effect(function()
a()
onEffectCleanup(function()
table.insert(order, "a")
end)
onEffectCleanup(function()
table.insert(order, "b")
end)
end)
assert(table.concat(order) == "", "should not cleanup immediately")
setA(true)
assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`)
table.clear(order)
stopEffect()
assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`)
table.clear(order)
setA(false)
assert(table.concat(order) == "", "should not cleanup after update")
end)
test("should run inner effect cleanup", function()
local cleanups = 0
local src, setSrc = signal(false)
local stopEffect = effect(function()
src()
effect(function()
onEffectCleanup(function()
cleanups += 1
end)
end)
effect(function()
onEffectCleanup(function()
cleanups += 1
end)
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
setSrc(true)
assert(cleanups == 2, "should cleanup after outer update")
stopEffect()
assert(cleanups == 4, "should cleanup after outer dispose")
setSrc(false)
assert(cleanups == 4, "should not cleanup after update")
end)
test("should run cleanups outside any evaluation context", function()
local calls = 0
local a, setA = signal(0)
local b, setB = signal(0)
local c = computed(function()
if a() == 0 then
effect(function()
onEffectCleanup(function()
b()
end)
end)
end
return a()
end)
effect(function()
calls += 1
c()
end)
assert(calls == 1, "should run once")
calls = 0
setA(1)
assert(calls == 1, "should run once after a update")
calls = 0
setB(1)
assert(calls == 0, "should not run after b update")
end)
return {}

View file

@ -0,0 +1,75 @@
local signals = require("../../src/signals")
local suite = require("@test/suite")
local effect = signals.effect
local effectScope = signals.effectScope
local signal = signals.signal
local onEffectCleanup = signals.onEffectCleanup
local onScopeDispose = signals.onScopeDispose
local test = suite.test
test("should not trigger after stop", function()
local count, setCount = signal(1)
local triggers = 0
local stopScope = effectScope(function()
effect(function()
triggers += 1
count()
end)
assert(triggers == 1, "did not run initially")
setCount(2)
assert(triggers == 2, "did not run after update")
end)
setCount(3)
assert(triggers == 3, "did not run after update outside scope")
stopScope()
setCount(4)
assert(triggers == 3, "ran after stop")
end)
test("should run indirect scope cleanup", function()
local cleanups = 0
local cleanup
local stopScope = effectScope(function()
cleanup = effectScope(function()
onScopeDispose(function()
cleanups += 1
end)
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
stopScope()
assert(cleanups == 1, "should cleanup from parent dispose")
cleanup()
assert(cleanups == 1, "should skip redundant dispose")
end)
test("should run indirect effect cleanup", function()
local cleanups = 0
local a, setA = signal(false)
local stopScope = effectScope(function()
effect(function()
a()
onEffectCleanup(function()
cleanups += 1
end)
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
setA(true)
assert(cleanups == 1, "should cleanup once")
stopScope()
assert(cleanups == 2, "should cleanup after outer dispose")
setA(false)
assert(cleanups == 2, "should not cleanup after update")
end)
return {}

View file

@ -0,0 +1,72 @@
local signals = require("../../src/signals")
local suite = require("@test/suite")
local signal = signals.signal
local computed = signals.computed
local effect = signals.effect
local effectScope = signals.effectScope
local test = suite.test
--- Creates a weak reference to a value.
--- @return Function to dereference the value.
local function weakRef<T>(value: T): () -> T?
local ref = setmetatable({ value = value }, { __mode = "v" })
return function()
return ref.value
end
end
--- Triggers garbage collection by allocating excessive memory.
local function gc()
for _ = 1, 1000 do
local _ = table.create(100, 0)
end
end
--- In alien-signals, a computed value unlinks from dependencies when its last
--- parent scope is disposed. Calling a computed value outside of a scope links
--- it to dependencies, but the dependencies hold strong references to the
--- computed value, so it won't GC until called in a scope and disposed.
---
--- This function releases a computed value by linking it to a scope and then
--- immediately closing it, causing alien-signals to unlink the computed value
--- and allow it to be garbage collected.
local function release(getter: () -> ())
effectScope(function()
getter()
end)()
end
test("should release computed cache", function()
local srcRef = weakRef({})
local src, setSrc = signal(srcRef())
local c = computed(function()
return src()
end)
c() -- cache src value
setSrc(nil) -- release value
c = release(c) -- release computed
gc()
assert(srcRef() == nil, "should release computed cache")
end)
test("should not release effect for signal", function()
local effectCalls = 0
local src, setSrc = signal(0)
effect(function()
effectCalls += 1
src()
end)
assert(effectCalls == 1, "should call effect once")
gc()
setSrc(src() + 1)
assert(effectCalls == 2, "should call effect again")
end)
return {}

View file

@ -0,0 +1,113 @@
local signals = require("../../src/signals")
local suite = require("@test/suite")
local computed = signals.computed
local effect = signals.effect
local setCurrentSub = signals.setCurrentSub
local signal = signals.signal
local test = suite.test
type ReactionOptions<T = unknown> = {
fireImmediately: boolean?,
equals: ((a: T, b: T?) -> boolean)?,
onError: ((error: unknown) -> ())?,
scheduler: ((fn: () -> ()) -> ())?,
once: boolean?,
}
local function untracked<T>(callback: () -> T): T
local currentSub = setCurrentSub(nil)
local success, result = pcall(callback)
setCurrentSub(currentSub)
if not success then
error(result)
end
return result
end
local function reaction<T>(
dataFn: () -> T,
effectFn: (newValue: T, oldValue: T?) -> (),
options: ReactionOptions<T>?
): () -> ()
local options: ReactionOptions<T> = options or {}
local scheduler: (() -> ()) -> () = options.scheduler or function(fn)
return fn()
end
local equals: (T, T?) -> boolean = options.equals or function(a, b)
return a == b
end
local onError = options.onError
local once = options.once or false
local fireImmediately = options.fireImmediately or false
local prevValue: T?
local version = 0
local tracked = computed(function()
local success, result = pcall(dataFn)
if not success then
untracked(function()
if onError then
onError(result)
end
return nil
end)
return prevValue
end
return result
end)
local dispose
dispose = effect(function()
local current = tracked()
if not fireImmediately and version == 0 then
prevValue = current
end
version += 1
if equals(current :: T, prevValue) then
return
end
local oldValue = prevValue
prevValue = current
untracked(function()
scheduler(function()
local success = pcall(effectFn, current :: T, oldValue)
if not success and onError then
onError(error)
end
if once then
if (fireImmediately and version > 1) or (not fireImmediately and version > 0) then
dispose()
end
end
end)
return nil
end)
end)
return dispose
end
test("#48", function()
local source, setSource = signal(0)
local disposeInner: () -> ()
reaction(function()
return source()
end, function(val)
if val == 1 then
disposeInner = reaction(function()
return source()
end, function() end)
elseif val == 2 then
disposeInner()
end
end)
setSource(1)
setSource(2)
setSource(3)
end)
return {}

View file

@ -0,0 +1,27 @@
local suite = require("@test/suite")
suite.describe("signals", function()
suite.describe("computed", function()
require("./computed.spec")
end)
suite.describe("effect", function()
require("./effect.spec")
end)
suite.describe("effectScope", function()
require("./effectScope.spec")
end)
suite.describe("issue_48", function()
require("./issue_48.spec")
end)
suite.describe("gc", function()
require("./gc.spec")
end)
suite.describe("topology", function()
require("./topology.spec")
end)
suite.describe("untrack", function()
require("./untrack.spec")
end)
end)
return {}

View file

@ -0,0 +1,492 @@
local signals = require("../../src/signals")
local suite = require("@test/suite")
local computed = signals.computed
local effect = signals.effect
local signal = signals.signal
local describe = suite.describe
local test = suite.test
--[[
Tests adopted with thanks from preact-signals implementation at
https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx
The MIT License (MIT)
Copyright (c) 2022-present Preact Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
]]
describe("graph updates", function()
test("should drop A->B->A updates", function()
-- A
-- / |
-- B | <- Looks like a flag doesn't it? :D
-- \ |
-- C
-- |
-- D
local a, setA = signal(2)
local b = computed(function()
return a() - 1
end)
local c = computed(function()
return a() + b()
end)
local computeCalls = 0
local compute = function()
computeCalls += 1
return "d: " .. c()
end
local d = computed(compute)
-- Trigger read
assert(d() == "d: 3", "d should be 3")
assert(computeCalls == 1, "compute should be called once")
computeCalls = 0
setA(4)
d()
assert(computeCalls == 1, "compute should drop update")
end)
test("should only update every signal once (diamond graph)", function()
-- In this scenario "D" should only update once when "A" receives
-- an update. This is sometimes referred to as the "diamond" scenario.
-- A
-- / \
-- B C
-- \ /
-- D
local a, setA = signal("a")
local b = computed(function()
return a()
end)
local c = computed(function()
return a()
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return b() .. " " .. c()
end
local d = computed(spy)
assert(d() == "a a", "d should be 'a a'")
assert(spyCalls == 1, "compute should be called once")
setA("aa")
assert(d() == "aa aa", "d should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice")
end)
test("should only update every signal once (diamond graph + tail)", function()
-- "E" will be likely updated twice if our mark+sweep logic is buggy.
-- A
-- / \
-- B C
-- \ /
-- D
-- |
-- E
local a, setA = signal("a")
local b = computed(function()
return a()
end)
local c = computed(function()
return a()
end)
local d = computed(function()
return b() .. " " .. c()
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return d()
end
local e = computed(spy)
assert(e() == "a a", "e should be 'a a'")
assert(spyCalls == 1, "compute should be called once")
setA("aa")
assert(e() == "aa aa", "e should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice")
end)
test("should bail out if result is the same", function()
-- Bail out if value of "B" never changes
-- A->B->C
local a, setA = signal("a")
local b = computed(function()
a()
return "foo"
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return b()
end
local c = computed(spy)
assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should be called once")
setA("aa")
assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should bail out")
end)
test("should only update every signal once (jagged diamond graph + tails)", function()
-- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy.
-- A
-- / \
-- B C
-- | |
-- | D
-- \ /
-- E
-- / \
-- F G
local a, setA = signal("a")
local b = computed(function()
return a()
end)
local c = computed(function()
return a()
end)
local d = computed(function()
return c()
end)
local eSpyCalls = 0
local eSpyTime = 0
local eSpy = function()
eSpyCalls += 1
eSpyTime = os.clock()
return b() .. " " .. d()
end
local e = computed(eSpy)
local fSpyCalls = 0
local fSpyTime = 0
local fSpy = function()
fSpyCalls += 1
fSpyTime = os.clock()
return e()
end
local f = computed(fSpy)
local gSpyCalls = 0
local gSpyTime = 0
local gSpy = function()
gSpyCalls += 1
gSpyTime = os.clock()
return e()
end
local g = computed(gSpy)
assert(f() == "a a", "f should be 'a a'")
assert(fSpyCalls == 1, "f should be called once")
assert(g() == "a a", "g should be 'a a'")
assert(gSpyCalls == 1, "g should be called once")
eSpyCalls, eSpyTime = 0, 0
fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0
setA("b")
assert(e() == "b b", "e should be 'b b'")
assert(eSpyCalls == 1, "e should run once after update")
assert(f() == "b b", "f should be 'b b'")
assert(fSpyCalls == 1, "f should run once after update")
assert(g() == "b b", "g should be 'b b'")
assert(gSpyCalls == 1, "g should run once after update")
eSpyCalls, eSpyTime = 0, 0
fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0
setA("c")
assert(e() == "c c", "e should be 'c c'")
assert(eSpyCalls == 1, "e should run once after update")
assert(f() == "c c", "f should be 'c c'")
assert(fSpyCalls == 1, "f should run once after update")
assert(g() == "c c", "g should be 'c c'")
assert(gSpyCalls == 1, "g should run once after update")
-- top to bottom
assert(eSpyTime < fSpyTime, "e should be called before f")
-- left to right
assert(eSpyTime < gSpyTime, "e should be called before g")
end)
test("should only subscribe to signals listened to", function()
-- *A
-- / \
-- *B C <- we don't listen to C
local a, setA = signal("a")
local b = computed(function()
return a()
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return a()
end
computed(spy)
assert(b() == "a", "b should be 'a'")
assert(spyCalls == 0, "compute should not run")
setA("aa")
assert(b() == "aa", "b should be 'aa'")
assert(spyCalls == 0, "compute should not run")
end)
test("should only subscribe to signals listened to II", function()
-- Here both "B" and "C" are active in the beginning, but
-- "B" becomes inactive later. At that point it should
-- not receive any updates anymore.
-- *A
-- / \
-- *B D <- we don't listen to C
-- |
-- *C
local a, setA = signal("a")
local spyBCalls = 0
local spyB = function()
spyBCalls += 1
return a()
end
local b = computed(spyB)
local spyCCalls = 0
local spyC = function()
spyCCalls += 1
return b()
end
local c = computed(spyC)
local d = computed(function()
return a()
end)
local result = ""
local unsub = effect(function()
result = c()
end)
assert(result == "a", "result should be 'a'")
assert(d() == "a", "d should be 'a'")
spyBCalls = 0
spyCCalls = 0
unsub()
setA("aa")
assert(spyBCalls == 0, "b should not run")
assert(spyCCalls == 0, "c should not run")
assert(d() == "aa", "d should be 'aa'")
end)
test("should ensure subs update even if one dep unmarks it", function()
-- In this scenario "C" always returns the same value. When "A"
-- changes, "B" will update, then "C" at which point its update
-- to "D" will be unmarked. But "D" must still update because
-- "B" marked it. If "D" isn't updated, then we have a bug.
-- A
-- / \
-- B *C <- returns same value every time
-- \ /
-- D
local a, setA = signal("a")
local b = computed(function()
return a()
end)
local c = computed(function()
a()
return "c"
end)
local spyResult: string?
local spy = function()
spyResult = b() .. " " .. c()
return spyResult
end
local d = computed(spy)
assert(d() == "a c", "d should be 'a c'")
spyResult = nil
setA("aa")
d()
assert(spyResult == "aa c", "d should have returned 'aa c'")
end)
test("should ensure subs update even if two deps unmark it", function()
-- In this scenario both "C" and "D" always return the same
-- value. But "E" must still update because "A" marked it.
-- If "E" isn't updated, then we have a bug.
-- A
-- / | \
-- B *C *D
-- \ | /
-- E
local a, setA = signal("a")
local b = computed(function()
return a()
end)
local c = computed(function()
a()
return "c"
end)
local d = computed(function()
a()
return "d"
end)
local spyResult: string?
local spy = function()
spyResult = b() .. " " .. c() .. " " .. d()
return spyResult
end
local e = computed(spy)
assert(e() == "a c d", "e should be 'a c d'")
spyResult = nil
setA("aa")
e()
assert(spyResult == "aa c d", "e should have returned 'aa c d'")
end)
test("should support lazy branches", function()
local a, setA = signal(0)
local b = computed(function()
return a()
end)
local c = computed(function()
return if a() > 0 then a() else b()
end)
assert(c() == 0, "c should be 0")
setA(1)
assert(c() == 1, "c should be 1")
setA(0)
assert(c() == 0, "c should be 0 again")
end)
test("should not update a sub if all deps unmark it", function()
-- In this scenario "B" and "C" always return the same value. When "A"
-- changes, "D" should not update.
-- A
-- / \
-- *B *C
-- \ /
-- D
local a, setA = signal("a")
local b = computed(function()
a()
return "b"
end)
local c = computed(function()
a()
return "c"
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return b() .. " " .. c()
end
local d = computed(spy)
assert(d() == "b c", "d should be 'b c'")
spyCalls = 0
setA("aa")
assert(spyCalls == 0, "d should not run")
end)
end)
describe("error handling", function()
test("should keep graph consistent on errors during activation", function()
local a, setA = signal(0)
local b = computed(function()
error("fail")
end)
local c = computed(function()
return a()
end)
local success, result = pcall(function()
b()
end)
assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail")
setA(1)
assert(c() == 1, "c should recover and return 1")
end)
test("should keep graph consistent on errors in computeds", function()
local a, setA = signal(0)
local b = computed(function()
if a() == 1 then
error("fail")
end
return a()
end)
local c = computed(function()
return b()
end)
assert(c() == 0, "c should be 0")
setA(1)
local success, result = pcall(function()
b()
end)
assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail")
setA(2)
assert(c() == 2, "c should recover and return 2")
end)
end)
return {}

View file

@ -0,0 +1,23 @@
local signals = require("../../src/signals")
local suite = require("@test/suite")
local computed = signals.computed
local setCurrentSub = signals.setCurrentSub
local signal = signals.signal
local test = suite.test
test("should pause tracking", function()
local src, setSrc = signal(0)
local c = computed(function()
local currentSub = setCurrentSub(nil)
local value = src()
setCurrentSub(currentSub)
return value
end)
assert(c() == 0, "should not track src")
setSrc(1)
assert(c() == 0, "should not track src")
end)
return {}

View file

@ -0,0 +1,23 @@
local suite = require("@test/suite")
require("./signals/spec")
suite.describe("charm", function()
suite.describe("listen", function()
require("./listen.spec")
end)
suite.describe("mapped", function()
require("./mapped.spec")
end)
suite.describe("observe", function()
require("./observe.spec")
end)
suite.describe("subscribe", function()
require("./subscribe.spec")
end)
suite.describe("untracked", function()
require("./untracked.spec")
end)
end)
return {}

View file

@ -0,0 +1,121 @@
local Charm = require("../src")
local suite = require("@test/suite")
type Atom<T> = Charm.Atom<T>
local atom = Charm.atom
local computed = Charm.computed
local subscribe = Charm.subscribe
local listen = Charm.listen
local effect = Charm.effect
local test = suite.test
test("should subscribe to selector", function()
local source = atom(0)
local current, previous
local unsub = subscribe(function()
return source() + 1
end, function(a, b)
current = a
previous = b
end)
assert(not current and not previous, "should not run initially")
source(0)
assert(not current and not previous, "should skip redundant update")
source(1)
assert(current == 2 and previous == 1, "should run with new value")
unsub()
source(2)
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should run in untracked", function()
local runs = 0
local cleanups = 0
local a = atom(0)
local b = atom(0)
subscribe(a, function()
runs += 1
b()
effect(function()
return function()
cleanups += 1
end
end)
end)
assert(runs == 0, "should not run once on init")
assert(cleanups == 0, "should not cleanup yet")
a(1)
assert(runs == 1, "should run once on change")
assert(cleanups == 0, "should not cleanup yet")
b(1)
assert(runs == 1, "should not run on b change")
assert(cleanups == 0, "should not cleanup first call")
a(2)
assert(runs == 2, "should run on a change")
assert(cleanups == 1, "should cleanup first call")
end)
test("should ensure correct execution order", function()
local order = {}
local n1 = atom(0)
local n2 = atom(0)
local sum = computed(function()
return n1() + n2()
end)
subscribe(n1, function()
table.insert(order, 1)
n2(n2() + 1)
end)
subscribe(sum, function()
table.insert(order, 2)
end)
subscribe(n1, function()
table.insert(order, 3)
end)
n1(n1() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
end)
test("should track inner subs", function()
local order = {}
local a = atom(1)
local unsub = subscribe(a, function()
table.insert(order, 1)
listen(a, function()
table.insert(order, 2)
end)
listen(a, function()
table.insert(order, 3)
end)
end)
assert(table.concat(order) == "", "should not run immediately")
a(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
a(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
unsub()
a(a() + 1)
assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
end)
return {}

View file

@ -0,0 +1,86 @@
local Charm = require("../src")
local suite = require("@test/suite")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local untracked = Charm.untracked
local test = suite.test
test("should block tracking inside effects", function()
local a = atom(1)
local b = atom(2)
local spyCalls = 0
effect(function()
untracked(function()
spyCalls += 1
return a() + b()
end)
end)
assert(spyCalls == 1, "should be called once")
a(10)
b(20)
assert(spyCalls == 1, "should be called once")
end)
test("should block tracking even when run inside effect run inside untracked", function()
local s = atom(1)
local spyCalls = 0
untracked(function()
effect(function()
untracked(function()
spyCalls += 1
return s()
end)
end)
end)
assert(spyCalls == 1, "should be called once")
s(2)
assert(spyCalls == 1, "should be called once")
end)
test("should not cause atom assignments throw", function()
local a = atom(1)
local aChangedTime = atom(0)
local dispose = effect(function()
a()
untracked(function()
aChangedTime(aChangedTime() + 1)
end)
end)
a(2)
assert(aChangedTime() == 2, "aChangedTime should be 2")
a(3)
assert(aChangedTime() == 3, "aChangedTime should be 3")
dispose()
end)
test("should block tracking inside computed atoms", function()
local a = atom(1)
local b = atom(2)
local spyCalls = 0
local c = computed(function()
return untracked(function()
spyCalls += 1
return a() + b()
end)
end)
assert(spyCalls == 0, "should not be called yet")
assert(c() == 3, "c should be 3")
a(10)
c()
b(20)
c()
assert(spyCalls == 1, "should be called once")
assert(c() == 3, "c should be 3")
end)
return {}

View file

@ -0,0 +1,8 @@
[package]
name = "littensy/charm"
version = "0.10.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["tests"]
[dependencies]

View file

@ -0,0 +1,6 @@
{
"name": "ReactCharm",
"tree": {
"$path": "src"
}
}

View file

@ -0,0 +1,58 @@
local Charm = require(script.Parent.Charm)
local React = require(script.Parent.React)
local effect = Charm.effect
local useBinding = React.useBinding
local useEffect = React.useEffect
local useState = React.useState
local useMemo = React.useMemo
--- A hook that subscribes to changes in the given atom or selector. The
--- component is re-rendered whenever the state changes.
---
--- If the `dependencies` array is provided, the effect is re-created whenever
--- the dependencies change. Otherwise, the subscription is created once when
--- the component is mounted.
---
--- @param selector The atom or selector to subscribe to.
--- @param dependencies An array of values that the subscription depends on.
--- @return The current state.
local function useAtom<State>(selector: () -> State, dependencies: { any }?): State
local state, setState = useState(selector)
useEffect(function()
return effect(function()
setState(selector())
end)
end, dependencies or {})
return state
end
--- A hook that subscribes to changes in the given atom or selector. Returns
--- a binding that updates when the state changes.
---
--- If the `dependencies` array is provided, the effect is re-created whenever
--- the dependencies change. Otherwise, the subscription is created once when
--- the component is mounted.
---
--- @param selector The atom or selector to subscribe to.
--- @param dependencies An array of values that the subscription depends on.
--- @return A binding that stores the current state.
local function useAtomBinding<State>(selector: () -> State, dependencies: { any }?)
local initialState = useMemo(selector, {})
local binding, setBinding = useBinding(initialState)
useEffect(function()
return effect(function()
setBinding(selector())
end)
end, dependencies or {})
return binding
end
return {
useAtom = useAtom,
useAtomBinding = useAtomBinding,
}

View file

@ -0,0 +1,42 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
_G.__ROACT_17_MOCK_SCHEDULER__ = true
local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.Packages.ReactRoblox)
type RenderedHook<Props, Result> = {
result: Result,
rerender: (Props?) -> (),
unmount: () -> (),
}
local function renderHook<Props, Result>(callback: (Props) -> Result, props: Props?): RenderedHook<Props, Result>
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
local hook = {}
local function Component(props)
hook.result = callback(props)
end
function hook.rerender(newProps)
props = newProps or props
ReactRoblox.act(function()
root:render(React.createElement(Component, props))
end)
end
function hook.unmount()
ReactRoblox.act(function()
root:unmount()
end)
end
ReactRoblox.act(function()
hook.rerender(props)
end)
return hook
end
return renderHook

View file

@ -0,0 +1,14 @@
local ServerScriptService = game:GetService("ServerScriptService")
local suite = require(ServerScriptService.Tests.suite)
suite.describe("react-charm", function()
suite.describe("useAtom", function()
require("./useAtom.spec")
end)
suite.describe("useAtomBinding", function()
require("./useAtomBinding.spec")
end)
end)
return {}

View file

@ -0,0 +1,50 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm)
local renderHook = require(script.Parent.helpers.renderHook)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local useAtom = ReactCharm.useAtom
local test = suite.test
test("tracks atom state", function()
local state = atom(0)
local hook = renderHook(function()
return useAtom(state)
end)
assert(hook.result == 0, "should return 0")
state(1)
assert(hook.result == 1, "should return 1")
end)
test("accepts a selector", function()
local state = atom(0)
local hook = renderHook(function()
return useAtom(function()
return 2 * state()
end)
end)
assert(hook.result == 0, "should return 0")
state(1)
assert(hook.result == 2, "should return 2")
end)
test("tracks dependencies", function()
local state = atom(0)
local hook = renderHook(function()
return useAtom(state, { state })
end)
assert(hook.result == 0, "should return 0")
state(1)
assert(hook.result == 1, "should return 1")
state = atom(0)
hook.rerender()
assert(hook.result == 0, "should return 0")
state(1)
assert(hook.result == 1, "should return 1")
end)
return {}

View file

@ -0,0 +1,50 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm)
local renderHook = require(script.Parent.helpers.renderHook)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local useAtomBinding = ReactCharm.useAtomBinding
local test = suite.test
test("tracks atom state", function()
local state = atom(0)
local hook = renderHook(function()
return useAtomBinding(state)
end)
assert(hook.result:getValue() == 0, "should return 0")
state(1)
assert(hook.result:getValue() == 1, "should return 1")
end)
test("accepts a selector", function()
local state = atom(0)
local hook = renderHook(function()
return useAtomBinding(function()
return 2 * state()
end)
end)
assert(hook.result:getValue() == 0, "should return 0")
state(1)
assert(hook.result:getValue() == 2, "should return 2")
end)
test("tracks dependencies", function()
local state = atom(0)
local hook = renderHook(function()
return useAtomBinding(state, { state })
end)
assert(hook.result:getValue() == 0, "should return 0")
state(1)
assert(hook.result:getValue() == 1, "should return 1")
state = atom(0)
hook.rerender()
assert(hook.result:getValue() == 0, "should return 0")
state(1)
assert(hook.result:getValue() == 1, "should return 1")
end)
return {}

View file

@ -0,0 +1,10 @@
[package]
name = "littensy/react-charm"
version = "0.3.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["tests"]
[dependencies]
Charm = "littensy/charm@^0.10"
React = "jsdotlua/react@^17"

View file

@ -0,0 +1,6 @@
{
"name": "VideCharm",
"tree": {
"$path": "src"
}
}

View file

@ -0,0 +1,21 @@
local Charm = require(script.Parent.Charm)
local Vide = require(script.Parent.Vide)
local subscribe = Charm.subscribe
local source = Vide.source
local cleanup = Vide.cleanup
--- Subscribes to the state of an atom or selector. Returns a source that
--- updates when the state changes.
---
--- @param selector The atom or selector to subscribe to.
--- @return The reactive source.
local function useAtom<State>(selector: () -> State): () -> State
local state = source(selector())
cleanup(subscribe(selector, state))
return state
end
return {
useAtom = useAtom,
}

View file

@ -0,0 +1,11 @@
local ServerScriptService = game:GetService("ServerScriptService")
local suite = require(ServerScriptService.Tests.suite)
suite.describe("vide-charm", function()
suite.describe("useAtom", function()
require("./useAtom.spec")
end)
end)
return {}

View file

@ -0,0 +1,40 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local Vide = require(ReplicatedStorage.Packages.Vide)
local VideCharm = require(ReplicatedStorage.Packages.VideCharm)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local root = Vide.root
local useAtom = VideCharm.useAtom
local test = suite.test
test("tracks atom state", function()
local state = atom(0)
local source
local cleanup = root(function()
source = useAtom(state)
end)
assert(source() == 0, "should return 0")
state(1)
assert(source() == 1, "should return 1")
cleanup()
end)
test("accepts a selector", function()
local state = atom(0)
local source
local cleanup = root(function()
source = useAtom(function()
return 2 * state()
end)
end)
assert(source() == 0, "should return 0")
state(1)
assert(source() == 2, "should return 2")
cleanup()
end)
return {}

View file

@ -0,0 +1,10 @@
[package]
name = "littensy/vide-charm"
version = "0.3.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["tests"]
[dependencies]
Charm = "littensy/charm@^0.10"
Vide = "centau/vide@^0.3"