refactor: embrace scope cleanup, restructure code

This commit is contained in:
littensy 2025-05-05 22:05:41 -07:00
parent 3eba56e298
commit 8d385356cd
81 changed files with 687 additions and 626 deletions

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.charm.src)

View file

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

View file

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

View file

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

View file

@ -1,21 +0,0 @@
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
--[=[
Synchronizes state between the client and server. The server sends patches
to the client, which applies them to its local state.
]=]
return {
client = client,
server = server,
flatten = flatten,
validate = validate,
patch = patch,
isNone = patch.isNone,
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,54 +0,0 @@
local signals = require("./signals")
local types = require("./types")
type Atom<T> = types.Atom<T>
type AtomOptions<T> = types.AtomOptions<T>
local signal = signals.signal
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local function atomOper<T>(source: (...T) -> T, equals: ((T, T) -> boolean)?, ...: ((T) -> T) | T): T
if select("#", ...) == 0 then
return source()
end
local transform = (...)
local currentState = (source :: any).value
local nextState: T
-- If the transform is a function, call it with the current state and pause
-- tracking to avoid adding signals called by the transform.
if type(transform) == "function" then
pauseTracking()
local success, result = pcall(transform, currentState)
resumeTracking()
if not success then
error(result, 2)
end
nextState = result
else
nextState = transform
end
if equals and equals(currentState, nextState) then
return nextState
end
source(nextState)
return nextState
end
local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
local source = signal(state)
local equals = options and options.equals
return function(...)
return atomOper(source, equals, ...)
end
end
return atom

View file

@ -1,16 +0,0 @@
local signals = require("./signals")
local startBatch = signals.startBatch
local endBatch = signals.endBatch
local function batched(callback: () -> ())
startBatch()
local success, result: any = pcall(callback)
endBatch()
if not success then
error(result)
end
end
return batched

View file

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

View file

@ -1,7 +0,0 @@
local lib = require(script.lib)
export type Atom<T = any> = lib.Atom<T>
export type AtomOptions<T = any> = lib.AtomOptions<T>
export type Selector<T = any> = lib.Selector<T>
return lib

View file

@ -1,31 +0,0 @@
local atom = require("./atom")
local batched = require("./batched")
local effect = require("./effect")
local listen = require("./listen")
local mapped = require("./mapped")
local observe = require("./observe")
local signals = require("./signals")
local subscribe = require("./subscribe")
local types = require("./types")
local untracked = require("./untracked")
export type Atom<T = any> = types.Atom<T>
export type AtomOptions<T = any> = types.AtomOptions<T>
export type Selector<T = any> = types.Selector<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,
signals = signals,
}

View file

@ -1,45 +0,0 @@
local signals = require("./signals")
local spawn = require("./spawn")
local effect = signals.effect
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local function listen<T>(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> ()
local disposed = false
local mounted = true
local current: T?
local dispose: () -> ()
local function disposeSafe()
disposed = true
if dispose then
dispose()
end
end
dispose = effect(function()
local value = selector()
if mounted then
mounted = false
elseif value == current then
return
end
local previous = current
current = value
pauseTracking()
spawn(listener, value, previous, disposeSafe)
resumeTracking()
end)
if disposed then
dispose()
end
return disposeSafe
end
return listen

View file

@ -1,127 +0,0 @@
local signals = require("./signals")
local computed = signals.computed
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
type MappedKey<VI, KO, VO> = {
key: KO,
value: VO,
}
local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, transform: (item: VI, key: KI) -> (VO, KO?)): () -> { [KO]: VO }
local mappedKeys: { [KI]: MappedKey<VI, KO, VO> } = {}
local previousItems: { [KI]: VI }?
local function getTransform(value: VI, key: KI): (VO, KO?)
pauseTracking()
local success, valueOut, keyOut = pcall(transform, value, key)
resumeTracking()
if not success then
error(`observe() transform function failed: {valueOut}`)
end
return valueOut, keyOut
end
return computed(function(previousMappedItems)
local currentItems = selector()
-- If this is the initial run, create a map of the items and store the
-- updated keys and values.
if not previousItems or not previousMappedItems then
local mappedItems: { [KO]: VO } = {}
previousItems = currentItems
for key, value in currentItems do
local valueOut, keyOut = getTransform(value, key)
local keyOut = if keyOut ~= nil then keyOut else key :: never
if valueOut ~= nil then
mappedKeys[key] = { key = keyOut, value = valueOut }
mappedItems[keyOut] = valueOut
end
end
return mappedItems
end
local mappedItems = table.clone(previousMappedItems)
-- Update and remove mapped keys if the source values changed
for key, oldValue in previousItems do
local currentValue = currentItems[key]
local mappedKey = mappedKeys[key]
-- Remove the key if the source value changed from non-nil to nil
if currentValue == nil then
mappedKeys[key] = nil
if mappedKey then
mappedItems[mappedKey.key] = nil
end
continue
end
-- Do not call transform if the value has not changed
if currentValue == oldValue then
continue
end
local valueOut, keyOut = getTransform(currentValue, key)
local keyOut = if keyOut ~= nil then keyOut else key :: never
-- If there is no previous mappedKey, then this item was filtered
-- out to optimize memory. We need to check if the value is nil to
-- determine if we should add it back to the list.
if not mappedKey then
if valueOut ~= nil then
mappedKeys[key] = { key = keyOut, value = valueOut }
mappedItems[keyOut] = valueOut
end
continue
end
-- If the mapped value is nil, then delete mappedKey to save memory.
-- This is treated the same as setting mappedKey.value to nil.
if valueOut == nil then
mappedKeys[key] = nil
mappedItems[mappedKey.key] = nil
continue
end
-- If the key changed since the last transform call, remove
-- the old key and update mappedKey.
if keyOut ~= mappedKey.key then
mappedItems[mappedKey.key] = nil
mappedKey.key = keyOut
end
mappedKey.value = valueOut
mappedItems[keyOut] = valueOut
end
-- Add new keys if the value was not present in the previous state
for key, currentValue in currentItems do
local oldValue = previousItems[key]
if oldValue ~= nil then
continue
end
local valueOut, keyOut = getTransform(currentValue, key)
local keyOut = if keyOut ~= nil then keyOut else key :: never
if valueOut ~= nil then
mappedKeys[key] = { key = keyOut, value = valueOut }
mappedItems[keyOut] = valueOut
end
end
previousItems = currentItems
return mappedItems
end)
end
return mapped

View file

@ -1,38 +0,0 @@
local listen = require("./listen")
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> ()
local cleanups: { [K]: () -> ()? } = {}
local unsub = listen(selector, function(currentItems, previousItems)
if not previousItems then
for key, value in currentItems do
cleanups[key] = observer(value, key)
end
return
end
for key in previousItems do
local cleanup = cleanups[key]
if currentItems[key] == nil and cleanup then
cleanups[key] = nil
cleanup()
end
end
for key, value in currentItems do
if previousItems[key] == nil then
cleanups[key] = observer(value, key)
end
end
end)
return function()
unsub()
for _, cleanup in cleanups do
(cleanup :: () -> ())()
end
table.clear(cleanups)
end
end
return observe

View file

@ -1,400 +0,0 @@
--[=[
alien-signals 2.0.1 implementation with effect cleanups, 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 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 & {
cleanup: (() -> ())?,
}
type Effect = ReactiveNode & {
fn: () -> (() -> ())?,
cleanup: (() -> ())?,
}
type Computed<T = any> = ReactiveNode & {
value: T?,
getter: (previousValue: T?) -> T,
}
type Signal<T = any> = ReactiveNode & {
previousValue: T,
value: T,
}
local pauseStack: { ReactiveNode? } = {}
local pauseStackCount = 0
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: EffectScope?
local function getCurrentSub(): ReactiveNode?
return activeSub
end
local function setCurrentSub(sub: ReactiveNode?)
local prevSub = activeSub
activeSub = sub
return prevSub
end
local function getCurrentScope(): EffectScope?
return activeScope
end
local function setCurrentScope(scope: EffectScope?)
local prevScope = activeScope
activeScope = scope
return prevScope
end
local function pauseTracking()
pauseStackCount += 1
pauseStack[pauseStackCount] = activeSub
activeSub = nil
end
local function resumeTracking()
activeSub = pauseStack[pauseStackCount]
pauseStack[pauseStackCount] = nil
pauseStackCount -= 1
end
local function cleanup(e: ReactiveNode)
local e = e :: EffectScope
local cleanup = e.cleanup
if cleanup then
e.cleanup = nil
local prev = setCurrentSub(nil)
local success, result: unknown = pcall(cleanup)
setCurrentSub(prev)
if not success then
error(result)
end
end
end
local function run(e: EffectScope, flags: ReactiveFlags): ()
if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(e.deps :: Link, e)) then
cleanup(e)
local prev = setCurrentSub(e)
startTracking(e)
local success, result = pcall((e :: Effect).fn)
setCurrentSub(prev)
endTracking(e)
if not success then
error(result)
end
e.cleanup = result
return
elseif bit32.band(flags, PENDING) ~= 0 then
e.flags = bit32.band(flags, bit32.bnot(PENDING))
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 :: EffectScope, depFlags)
end
link = link.nextDep
end
end
local function flush(): ()
while notifyIndex < queuedEffectsLength do
notifyIndex += 1
local effect = queuedEffects[notifyIndex] :: EffectScope
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
local function updateComputed(c: Computed): boolean
local prevSub = setCurrentSub(c)
startTracking(c)
local oldValue = c.value
local success, result = pcall(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 signalOper<T>(this: Signal<T>, ...: T): T?
if select("#", ...) > 0 then
local newValue = (...)
if this.value ~= newValue then
this.value = newValue
this.flags = bit32.bor(MUTABLE, DIRTY)
local subs = this.subs
if subs then
propagate(subs)
if batchDepth == 0 then
flush()
end
end
end
return
else
local 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
end
local function computedOper<T>(this: Computed<T>): T
local flags = this.flags
if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(this.deps :: Link, this)) then
if updateComputed(this) then
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
elseif bit32.band(flags, PENDING) ~= 0 then
this.flags = bit32.band(flags, bit32.bnot(PENDING))
end
if activeSub then
link(this, activeSub)
elseif activeScope then
link(this, activeScope)
end
return this.value :: T
end
local function effectOper(this: EffectScope): ()
local dep = this.deps
while dep do
dep = unlink(dep, this)
end
local sub = this.subs
while sub do
unlink(sub)
sub = this.subs
end
this.flags = NONE
cleanup(this)
end
local signalMetatable = { __call = signalOper }
local computedMetatable = { __call = computedOper }
local effectMetatable = { __call = effectOper }
local function signal<T>(initialValue: T): ((T) -> ()) & (() -> T)
local this: Signal<T> = {
previousValue = initialValue,
value = initialValue,
flags = MUTABLE,
}
return setmetatable(this, signalMetatable) :: any
end
local function computed<T>(getter: (previousValue: T?) -> T): () -> T
local this: Computed<T> = {
flags = bit32.bor(MUTABLE, DIRTY),
getter = getter,
}
return setmetatable(this, computedMetatable) :: any
end
local function effect(fn: () -> ...() -> ()?): () -> ()
local e: Effect = {
fn = fn,
flags = WATCHING,
}
if activeSub then
link(e, activeSub)
elseif activeScope then
link(e, activeScope)
end
local prev = setCurrentSub(e)
local success, result = pcall(fn)
setCurrentSub(prev)
if not success then
error(result)
end
e.cleanup = result
return setmetatable(e, effectMetatable) :: any
end
local function effectScope(fn: () -> ...() -> ()?): () -> ()
local e: EffectScope = {
flags = NONE,
}
if activeScope then
link(e, activeScope)
end
local prev = setCurrentScope(e)
local success, result = pcall(fn)
setCurrentScope(prev)
if not success then
error(result)
end
e.cleanup = result
return setmetatable(e, effectMetatable) :: any
end
local function notify(e: ReactiveNode)
local flags = e.flags
if bit32.band(flags, QUEUED) == 0 then
e.flags = bit32.bor(flags, QUEUED)
local subs = e.subs
if subs then
notify(subs.sub :: Effect | EffectScope)
else
queuedEffectsLength += 1
queuedEffects[queuedEffectsLength] = e :: Effect | EffectScope
end
end
end
local function update(signal: ReactiveNode): boolean
if (signal :: Computed).getter then
return updateComputed(signal :: Computed)
else
return updateSignal(signal :: Signal, (signal :: Signal).value)
end
end
local function unwatched(signal: ReactiveNode)
local toRemove = signal.deps
if toRemove then
repeat
toRemove = unlink(toRemove, signal)
until not toRemove
signal.flags = bit32.bor(signal.flags, DIRTY)
end
cleanup(signal)
end
local reactiveSystem = createReactiveSystem({
update = update,
notify = notify,
unwatched = unwatched,
})
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,
signal = signal,
computed = computed,
effect = effect,
effectScope = effectScope,
}

View file

@ -1,36 +0,0 @@
local freeThread: thread?
local function run<T...>(thread: thread, fn: (T...) -> (), ...: T...): boolean
fn(...)
if freeThread then
return true
else
freeThread = thread
return false
end
end
local function runner()
local quit = false
repeat
quit = run(coroutine.yield())
until quit
end
local function spawn<T...>(callback: (T...) -> (), ...: T...)
local thread
if freeThread then
thread = freeThread
freeThread = nil
elseif task then
thread = task.spawn(runner)
else
thread = coroutine.create(runner)
coroutine.resume(thread)
end
coroutine.resume(thread, thread, callback, ...)
end
return spawn

View file

@ -1,34 +0,0 @@
local signals = require("./signals")
local spawn = require("./spawn")
local effect = signals.effect
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local function subscribe<T>(selector: () -> T, listener: (T, T) -> ()): () -> ()
local current: T
local mounted = true
local dispose: () -> ()
dispose = effect(function()
local value = selector()
if mounted then
current = value
mounted = false
elseif value ~= current then
local previous = current
current = value
pauseTracking()
spawn(listener, value, previous)
resumeTracking()
end
end)
return function()
dispose()
end
end
return subscribe

View file

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

View file

@ -1,9 +0,0 @@
export type Selector<T = any> = () -> T
export type Atom<T = any> = ((((T) -> T) | T) -> T) & (() -> T)
export type AtomOptions<T = any> = {
equals: ((prev: T, next: T) -> boolean)?,
}
return {}

View file

@ -1,18 +0,0 @@
local signals = require("./signals")
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local function untracked<T, Args...>(callback: ((Args...) -> T) | (Args...) -> (), ...: Args...): T
pauseTracking()
local success, result = pcall(callback, ...)
resumeTracking()
if not success then
error(result)
end
return result
end
return untracked

View file

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

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.charm.src)

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.react)

View file

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

View file

@ -1,32 +0,0 @@
local Charm = require(script.Parent.Charm)
local React = require(script.Parent.React)
local listen = Charm.listen
local useEffect = React.useEffect
local useState = React.useState
--[=[
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 subscription to the atom or
selector is re-created whenever the dependencies change. Otherwise, the
subscription is created once when the component is mounted.
@param callback 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>(callback: () -> State, dependencies: { any }?): State
local state, setState = useState(callback)
useEffect(function()
return listen(callback, setState)
end, dependencies or {})
return state
end
return {
useAtom = useAtom,
}

View file

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

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.charm.src)

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.vide.src)

View file

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

View file

@ -1,22 +0,0 @@
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 and returns a Vide source.
@param callback The atom or selector to subscribe to.
@return The reactive source.
]=]
local function useAtom<State>(callback: () -> State): () -> State
local state = source(callback())
cleanup(subscribe(callback, state))
return state
end
return {
useAtom = useAtom,
}

View file

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