mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
chore: housekeeping
This commit is contained in:
parent
4931515a95
commit
4edc952c05
77 changed files with 230 additions and 602 deletions
6
packages/charm-sync/default.project.json
Normal file
6
packages/charm-sync/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "CharmSync",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
76
packages/charm-sync/src/client.luau
Normal file
76
packages/charm-sync/src/client.luau
Normal 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
|
||||
29
packages/charm-sync/src/flatten.luau
Normal file
29
packages/charm-sync/src/flatten.luau
Normal 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
|
||||
17
packages/charm-sync/src/init.luau
Normal file
17
packages/charm-sync/src/init.luau
Normal 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,
|
||||
}
|
||||
28
packages/charm-sync/src/interval.luau
Normal file
28
packages/charm-sync/src/interval.luau
Normal 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
|
||||
109
packages/charm-sync/src/patch.luau
Normal file
109
packages/charm-sync/src/patch.luau
Normal 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,
|
||||
}
|
||||
252
packages/charm-sync/src/server.luau
Normal file
252
packages/charm-sync/src/server.luau
Normal 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
|
||||
19
packages/charm-sync/src/types.luau
Normal file
19
packages/charm-sync/src/types.luau
Normal 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
|
||||
66
packages/charm-sync/src/validate.luau
Normal file
66
packages/charm-sync/src/validate.luau
Normal 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
|
||||
124
packages/charm-sync/tests/client.spec.luau
Normal file
124
packages/charm-sync/tests/client.spec.luau
Normal 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 {}
|
||||
28
packages/charm-sync/tests/flatten.spec.luau
Normal file
28
packages/charm-sync/tests/flatten.spec.luau
Normal 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 {}
|
||||
130
packages/charm-sync/tests/patch.spec.luau
Normal file
130
packages/charm-sync/tests/patch.spec.luau
Normal 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 {}
|
||||
102
packages/charm-sync/tests/server.spec.luau
Normal file
102
packages/charm-sync/tests/server.spec.luau
Normal 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 {}
|
||||
23
packages/charm-sync/tests/spec.luau
Normal file
23
packages/charm-sync/tests/spec.luau
Normal 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 {}
|
||||
38
packages/charm-sync/tests/validate.spec.luau
Normal file
38
packages/charm-sync/tests/validate.spec.luau
Normal 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 {}
|
||||
9
packages/charm-sync/wally.toml
Normal file
9
packages/charm-sync/wally.toml
Normal 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"
|
||||
6
packages/charm/default.project.json
Normal file
6
packages/charm/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "Charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
48
packages/charm/src/atom.luau
Normal file
48
packages/charm/src/atom.luau
Normal 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
|
||||
22
packages/charm/src/batched.luau
Normal file
22
packages/charm/src/batched.luau
Normal 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
|
||||
26
packages/charm/src/effect.luau
Normal file
26
packages/charm/src/effect.luau
Normal 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
|
||||
14
packages/charm/src/effectScope.luau
Normal file
14
packages/charm/src/effectScope.luau
Normal 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
|
||||
35
packages/charm/src/init.luau
Normal file
35
packages/charm/src/init.luau
Normal 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,
|
||||
}
|
||||
38
packages/charm/src/listen.luau
Normal file
38
packages/charm/src/listen.luau
Normal 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
|
||||
72
packages/charm/src/mapped.luau
Normal file
72
packages/charm/src/mapped.luau
Normal 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
|
||||
20
packages/charm/src/noYield.luau
Normal file
20
packages/charm/src/noYield.luau
Normal 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
|
||||
56
packages/charm/src/observe.luau
Normal file
56
packages/charm/src/observe.luau
Normal 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
|
||||
20
packages/charm/src/onCleanup.luau
Normal file
20
packages/charm/src/onCleanup.luau
Normal 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
|
||||
430
packages/charm/src/signals.luau
Normal file
430
packages/charm/src/signals.luau
Normal 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,
|
||||
}
|
||||
33
packages/charm/src/subscribe.luau
Normal file
33
packages/charm/src/subscribe.luau
Normal 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
|
||||
357
packages/charm/src/system.luau
Normal file
357
packages/charm/src/system.luau
Normal 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,
|
||||
}
|
||||
11
packages/charm/src/types.luau
Normal file
11
packages/charm/src/types.luau
Normal 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 {}
|
||||
21
packages/charm/src/untracked.luau
Normal file
21
packages/charm/src/untracked.luau
Normal 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
|
||||
101
packages/charm/tests/listen.spec.luau
Normal file
101
packages/charm/tests/listen.spec.luau
Normal 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 {}
|
||||
108
packages/charm/tests/mapped.spec.luau
Normal file
108
packages/charm/tests/mapped.spec.luau
Normal 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 {}
|
||||
64
packages/charm/tests/observe.spec.luau
Normal file
64
packages/charm/tests/observe.spec.luau
Normal 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 {}
|
||||
83
packages/charm/tests/signals/computed.spec.luau
Normal file
83
packages/charm/tests/signals/computed.spec.luau
Normal 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 {}
|
||||
359
packages/charm/tests/signals/effect.spec.luau
Normal file
359
packages/charm/tests/signals/effect.spec.luau
Normal 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 {}
|
||||
75
packages/charm/tests/signals/effectScope.spec.luau
Normal file
75
packages/charm/tests/signals/effectScope.spec.luau
Normal 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 {}
|
||||
72
packages/charm/tests/signals/gc.spec.luau
Normal file
72
packages/charm/tests/signals/gc.spec.luau
Normal 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 {}
|
||||
113
packages/charm/tests/signals/issue_48.spec.luau
Normal file
113
packages/charm/tests/signals/issue_48.spec.luau
Normal 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 {}
|
||||
27
packages/charm/tests/signals/spec.luau
Normal file
27
packages/charm/tests/signals/spec.luau
Normal 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 {}
|
||||
492
packages/charm/tests/signals/topology.spec.luau
Normal file
492
packages/charm/tests/signals/topology.spec.luau
Normal 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 {}
|
||||
23
packages/charm/tests/signals/untrack.spec.luau
Normal file
23
packages/charm/tests/signals/untrack.spec.luau
Normal 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 {}
|
||||
23
packages/charm/tests/spec.luau
Normal file
23
packages/charm/tests/spec.luau
Normal 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 {}
|
||||
121
packages/charm/tests/subscribe.spec.luau
Normal file
121
packages/charm/tests/subscribe.spec.luau
Normal 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 {}
|
||||
86
packages/charm/tests/untracked.spec.luau
Normal file
86
packages/charm/tests/untracked.spec.luau
Normal 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 {}
|
||||
8
packages/charm/wally.toml
Normal file
8
packages/charm/wally.toml
Normal 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]
|
||||
6
packages/react-charm/default.project.json
Normal file
6
packages/react-charm/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "ReactCharm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
58
packages/react-charm/src/init.luau
Normal file
58
packages/react-charm/src/init.luau
Normal 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,
|
||||
}
|
||||
42
packages/react-charm/tests/helpers/renderHook.luau
Normal file
42
packages/react-charm/tests/helpers/renderHook.luau
Normal 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
|
||||
14
packages/react-charm/tests/spec.luau
Normal file
14
packages/react-charm/tests/spec.luau
Normal 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 {}
|
||||
50
packages/react-charm/tests/useAtom.spec.luau
Normal file
50
packages/react-charm/tests/useAtom.spec.luau
Normal 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 {}
|
||||
50
packages/react-charm/tests/useAtomBinding.spec.luau
Normal file
50
packages/react-charm/tests/useAtomBinding.spec.luau
Normal 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 {}
|
||||
10
packages/react-charm/wally.toml
Normal file
10
packages/react-charm/wally.toml
Normal 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"
|
||||
6
packages/vide-charm/default.project.json
Normal file
6
packages/vide-charm/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "VideCharm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
21
packages/vide-charm/src/init.luau
Normal file
21
packages/vide-charm/src/init.luau
Normal 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,
|
||||
}
|
||||
11
packages/vide-charm/tests/spec.luau
Normal file
11
packages/vide-charm/tests/spec.luau
Normal 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 {}
|
||||
40
packages/vide-charm/tests/useAtom.spec.luau
Normal file
40
packages/vide-charm/tests/useAtom.spec.luau
Normal 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 {}
|
||||
10
packages/vide-charm/wally.toml
Normal file
10
packages/vide-charm/wally.toml
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue