feat: use alien-signals

This commit is contained in:
littensy 2025-05-03 18:24:39 -07:00
parent d7a1f62c3c
commit 861ee5acd7
76 changed files with 3112 additions and 1862 deletions

View file

@ -1,13 +1,6 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
on: [push, pull_request]
jobs:
analyze:
@ -17,9 +10,7 @@ jobs:
- uses: pnpm/action-setup@v4
with:
run_install: true
- name: Install Rokit
uses: CompeyDev/setup-rokit@v0.1.2
- uses: CompeyDev/setup-rokit@v0.1.2
- name: Install Wally dependencies
run: pnpm wally

View file

@ -10,9 +10,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Install Rokit
uses: CompeyDev/setup-rokit@v0.1.2
- uses: CompeyDev/setup-rokit@v0.1.2
- name: Build models
run: pnpm build

1
.gitignore vendored
View file

@ -11,3 +11,4 @@
*.tgz
*.rbxm
.DS_Store
roblox.d.luau

View file

@ -1,3 +1,6 @@
{
"languageMode": "strict"
"languageMode": "strict",
"lint": {
"LocalShadow": false
}
}

View file

@ -1,29 +1,18 @@
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau
rojo sourcemap -o sourcemap.json
check() {
echo "Checking $1"
luau-lsp analyze \
luau-lsp analyze \
--defs=bin/roblox.d.luau \
--defs=bin/testez.d.luau \
--flag:LuauFixIndexerSubtypingOrdering=true \
--flag:LuauInstantiateInSubtyping=true \
--sourcemap=sourcemap.json \
--ignore="**/_Index/**" \
$1
packages tests
selene $1
stylua --check $1
}
selene packages tests
stylua --check packages tests
eslint packages
for i in packages/*/src; do
check $i
done
check tests
rm bin/roblox.d.luau

1
bin/test.sh Normal file
View file

@ -0,0 +1 @@
lute tests/spec.server.luau

View file

@ -1,24 +0,0 @@
declare function afterAll(callback: () -> ()): ()
declare function afterEach(callback: () -> ()): ()
declare function beforeAll(callback: () -> ()): ()
declare function beforeEach(callback: () -> ()): ()
declare function describe(phrase: string, callback: () -> ()): ()
declare function describeFOCUS(phrase: string, callback: () -> ()): ()
declare function fdescribe(phrase: string, callback: () -> ()): ()
declare function describeSKIP(phrase: string, callback: () -> ()): ()
declare function xdescribe(phrase: string, callback: () -> ()): ()
declare function expect(value: any): any
declare function FIXME(optionalMessage: string?): ()
declare function FOCUS(): ()
declare function SKIP(): ()
declare function it(phrase: string, callback: () -> ()): ()
declare function itFOCUS(phrase: string, callback: () -> ()): ()
declare function fit(phrase: string, callback: () -> ()): ()
declare function itSKIP(phrase: string, callback: () -> ()): ()
declare function xit(phrase: string, callback: () -> ()): ()
declare function itFIXME(phrase: string, callback: () -> ()): ()

View file

@ -7,5 +7,4 @@ realm = "shared"
[dev-dependencies]
React = "jsdotlua/react@17.2.0"
ReactRoblox = "jsdotlua/react-roblox@17.2.0"
TestEZ = "roblox/testez@0.4.1"
Vide = "centau/vide@0.3.1"

View file

@ -20,5 +20,5 @@
"roblox-ts": "^3.0.0",
"typescript": "~5.5.4"
},
"packageManager": "pnpm@9.15.4"
"packageManager": "pnpm@10.10.0"
}

View file

@ -67,7 +67,7 @@ local function client(options: ClientOptions): ClientSyncer
for index = 1, select("#", ...) do
local payload: SyncPayload = select(index, ...)
Charm.batch(function()
Charm.batched(function()
if payload.type == "init" then
hydrate(payload.data)
elseif not ignoreUnhydrated or hydrated then

View file

@ -1,42 +1,54 @@
local store = require(script.Parent.store)
local types = require(script.Parent.types)
local signals = require("./signals")
local types = require("./types")
type Atom<T> = types.Atom<T>
type AtomOptions<T> = types.AtomOptions<T>
--[=[
Creates a new atom with the given state.
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
@param state The initial state.
@param options Optional configuration.
@return A new atom.
]=]
local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
local source = signal(state)
local equals = options and options.equals
local function atom(...)
if select("#", ...) == 0 then
local index = store.capturing.index
if index > 0 then
store.capturing.stack[index][atom] = true
return function(...)
return atomOper(source, equals, ...)
end
return state
end
local nextState = store.peek(..., state)
if state ~= nextState and not (equals and equals(state, nextState)) then
state = nextState
store.notify(atom)
end
return state
end
store.listeners[atom] = setmetatable({}, { __mode = "v" })
return atom
end
return atom

View file

@ -0,0 +1,16 @@
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,37 +1,13 @@
local atom = require(script.Parent.atom)
local store = require(script.Parent.store)
local types = require(script.Parent.types)
type AtomOptions<T> = types.AtomOptions<T>
type Selector<T> = types.Selector<T>
local signals = require("./signals")
--[=[
Creates a read-only atom that derives its state from one or more atoms.
Used to avoid unnecessary recomputations if multiple listeners depend on
the same atoms.
local computedImpl = signals.computed
@param callback The function that produces the state.
@param options Optional configuration.
@return A new read-only atom.
]=]
local function computed<T>(callback: Selector<T>, options: AtomOptions<T>?): Selector<T>
local dependencies, state = store.capture(callback)
local computedAtom = atom(state, options)
local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" })
local function computed<T>(selector: (previous: T?) -> T): () -> T
local getter = computedImpl(selector)
local function listener()
local computedAtom = computedRef.current
if computedAtom then
store.disconnect(dependencies, listener)
dependencies, state = store.capture(callback)
store.connect(dependencies, listener, computedAtom)
computedAtom(state)
return function()
return getter()
end
end
store.connect(dependencies, listener, computedAtom)
return computedAtom
end
return computed

View file

@ -1,53 +1,30 @@
local store = require(script.Parent.store)
local effectScope = require("./effectScope")
local signals = require("./signals")
type Cleanup = () -> ()
local effectImpl = signals.effect
--[=[
Runs the given callback immediately and whenever any atom it depends on
changes. Returns a cleanup function that unsubscribes the callback.
local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> ()
local disposed = false
local dispose: () -> ()
@param callback The function to run.
@return A function that unsubscribes the callback.
]=]
local function effect(callback: (cleanup: Cleanup) -> Cleanup?): Cleanup
local dependencies = {}
local cleanup: Cleanup?
local disconnected = false
local disconnect
local function listener()
if cleanup then
cleanup()
end
store.disconnect(dependencies, listener)
dependencies, cleanup = store.capture(callback, disconnect)
if not disconnected then
store.connect(dependencies, listener)
local function disposeSafe()
disposed = true
if dispose then
dispose()
end
end
function disconnect()
if disconnected then
return
dispose = effectScope(function()
effectImpl(function()
return callback(disposeSafe)
end)
end)
if disposed then
dispose()
end
disconnected = true
store.disconnect(dependencies, listener)
if cleanup then
cleanup()
end
end
dependencies, cleanup = store.capture(callback, disconnect)
if not disconnected then
store.connect(dependencies, listener)
end
return disconnect
return disposeSafe
end
return effect :: (callback: ((cleanup: Cleanup) -> ()) | ((cleanup: Cleanup) -> Cleanup?)) -> Cleanup
return effect

View file

@ -0,0 +1,13 @@
local signals = require("./signals")
local effectScopeImpl = signals.effectScope
local function effectScope(callback: () -> ...() -> ()?): () -> ()
local dispose = effectScopeImpl(callback)
return function()
dispose()
end
end
return effectScope

View file

@ -1,7 +1,7 @@
export = Charm;
export as namespace Charm;
type Cleanup = () => void;
type Dispose = () => void;
declare namespace Charm {
/**
@ -11,7 +11,8 @@ declare namespace Charm {
* @param state The next state or a function that produces the next state.
* @returns The current state.
*/
interface Atom<State> extends Selector<State> {
interface Atom<State> {
(): State;
(nextState: State | ((prevState: State) => State)): State;
}
@ -23,19 +24,10 @@ declare namespace Charm {
*/
type Selector<State> = () => State;
/**
* A function that depends on one or more atoms and produces a value. Can be
* used to derive state from atoms.
*
* @deprecated Use `Selector<T>` instead.
* @returns The current state.
*/
type Molecule<State> = Selector<State>;
/**
* Infers the type of the state produced by the given function.
*/
type StateOf<T> = T extends Selector<infer State> ? State : never;
type StateOf<T> = T extends () => infer State ? State : never;
interface AtomOptions<State> {
/**
@ -63,21 +55,34 @@ declare namespace Charm {
* the same atoms.
*
* @param callback The function that produces the state.
* @param options Optional configuration.
* @returns A new read-only atom.
*/
function computed<State>(callback: Selector<State>, options?: AtomOptions<State>): Selector<State>;
function computed<State>(callback: (prevResult?: State) => State): () => State;
/**
* Subscribes to changes in the given atom or selector. The callback is
* called with the current state and the previous state immediately after a
* change occurs.
* called with the current state and the previous state only after a change
* occurs.
*
* @param callback The atom or selector to subscribe to.
* @param listener The function to call when the state changes.
* @returns A function that unsubscribes the callback.
*/
function subscribe<State>(callback: Selector<State>, listener: (state: State, prev: State) => void): Cleanup;
function subscribe<State>(callback: () => State, listener: (state: State, prevState: State) => void): Dispose;
/**
* Subscribes to changes in the given atom or selector. The callback is
* called with the current state and the previous state immediately after
* subscribing, and again when a change occurs.
*
* @param callback The atom or selector to subscribe to.
* @param listener The function to call when the state changes.
* @returns A function that unsubscribes the callback.
*/
function listen<State>(
callback: () => State,
listener: (state: State, prevState: State, dispose: Dispose) => void,
): Dispose;
/**
* Runs the given callback immediately and whenever any atom it depends on
@ -86,25 +91,7 @@ declare namespace Charm {
* @param callback The function to run.
* @returns A function that unsubscribes the callback.
*/
function effect(callback: () => Cleanup | void): Cleanup;
/**
* Returns the result of the function without subscribing to changes. If a
* non-function value is provided, it is returned as is.
*
* @param callback The atom or selector to get the state of.
* @param args Arguments to pass to the function.
* @returns The current state.
*/
function peek<State, Args extends unknown[]>(callback: State | ((...args: Args) => State), ...args: Args): State;
/**
* Returns whether the given value is an atom.
*
* @param value The value to check.
* @returns `true` if the value is an atom, otherwise `false`.
*/
function isAtom(value: unknown): value is Atom<any>;
function effect(callback: (dispose: Dispose) => Dispose | void): Dispose;
/**
* Runs the given function and schedules listeners to be notified only once
@ -112,24 +99,16 @@ declare namespace Charm {
*
* @param callback The function to run.
*/
function batch(callback: () => void): void;
function batched(callback: () => void): void;
/**
* Captures all atoms that are read during the function call and returns them
* along with the result of the function. Useful for tracking dependencies.
* Returns the result of the function without subscribing to changes.
*
* @param callback The function to run.
* @returns A tuple containing the captured atoms and the result of the function.
* @param callback The atom or selector to get the state of.
* @param args Arguments to pass to the function.
* @returns The current state.
*/
function capture<State>(callback: Selector<State>): LuaTuple<[dependencies: Set<Atom<unknown>>, state: State]>;
/**
* Notifies all subscribers of the given atom that the state has changed.
* Mainly for running effects, since subscriptions check for equality.
*
* @param atom The atom to notify.
*/
function notify<State>(atom: Atom<State>): void;
function untracked<State, Args extends unknown[]>(callback: (...args: Args) => State, ...args: Args): State;
/**
* Creates an instance of `factory` for each item in the atom's state, and
@ -141,47 +120,46 @@ declare namespace Charm {
* @returns A function that unsubscribes all instances.
*/
function observe<Item>(
callback: Selector<readonly Item[]>,
factory: (item: Item, index: number) => Cleanup | void,
): Cleanup;
callback: () => readonly Item[],
factory: (item: Item, index: number) => Dispose | void,
): Dispose;
function observe<Key, Item>(
callback: Selector<Map<Key, Item> | ReadonlyMap<Key, Item>>,
factory: (item: Item, key: Key) => Cleanup | void,
): Cleanup;
callback: () => Map<Key, Item> | ReadonlyMap<Key, Item>,
factory: (item: Item, key: Key) => Dispose | void,
): Dispose;
function observe<Key extends string | number | symbol, Item>(
callback: Selector<{ readonly [P in Key]: Item }>,
factory: (item: Item, key: Key) => Cleanup | void,
): Cleanup;
callback: () => { readonly [P in Key]: Item },
factory: (item: Item, key: Key) => Dispose | void,
): Dispose;
/**
* Maps each entry in the atom's state to a new key-value pair. If the `mapper`
* function returns `undefined`, the entry is omitted from the resulting map.
* When the atom changes, the `mapper` is called for each entry in the state
* to compute the new state.
* When the atom changes, the `mapper` only runs for the changed entries.
*
* @param callback The atom or selector to map.
* @param mapper The function that maps each entry.
* @returns A new atom with the mapped state.
*/
function mapped<V0, K1, V1>(
callback: Selector<readonly V0[]>,
callback: () => readonly V0[],
mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>,
): Selector<ReadonlyMap<K1, V1>>;
): () => ReadonlyMap<K1, V1>;
function mapped<V0, V1>(
callback: Selector<readonly V0[]>,
callback: () => readonly V0[],
mapper: (value: V0, index: number) => V1,
): Selector<readonly V1[]>;
): () => readonly V1[];
function mapped<K0, V0, K1 = K0, V1 = V0>(
callback: Selector<Map<K0, V0> | ReadonlyMap<K0, V0>>,
callback: () => Map<K0, V0> | ReadonlyMap<K0, V0>,
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
): Selector<ReadonlyMap<K1, V1>>;
): () => ReadonlyMap<K1, V1>;
function mapped<K0 extends string | number | symbol, V0, K1 = K0, V1 = V0>(
callback: Selector<{ readonly [P in K0]: V0 }>,
callback: () => { readonly [P in K0]: V0 },
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
): Selector<ReadonlyMap<K1, V1>>;
): () => ReadonlyMap<K1, V1>;
}

View file

@ -1,27 +1,7 @@
local atom = require(script.atom)
local computed = require(script.computed)
local effect = require(script.effect)
local mapped = require(script.mapped)
local observe = require(script.observe)
local store = require(script.store)
local subscribe = require(script.subscribe)
local types = require(script.types)
local lib = require(script.lib)
export type Atom<State> = types.Atom<State>
export type Selector<State> = types.Selector<State>
export type Molecule<State> = types.Selector<State>
export type SyncPayload = types.SyncPayload
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 {
atom = atom,
computed = computed,
effect = effect,
mapped = mapped,
observe = observe,
subscribe = subscribe,
batch = store.batch,
capture = store.capture,
isAtom = store.isAtom,
notify = store.notify,
peek = store.peek,
}
return lib

View file

@ -0,0 +1,28 @@
local atom = require("./atom")
local batched = require("./batched")
local computed = require("./computed")
local effect = require("./effect")
local effectScope = require("./effectScope")
local listen = require("./listen")
local mapped = require("./mapped")
local observe = require("./observe")
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>
return {
atom = atom,
batched = batched,
computed = computed,
effect = effect,
effectScope = effectScope,
listen = listen,
mapped = mapped,
observe = observe,
subscribe = subscribe,
untracked = untracked,
}

View file

@ -0,0 +1,40 @@
local effectScope = require("./effectScope")
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 current: T?
local dispose: () -> ()
local function disposeSafe()
disposed = true
if dispose then
dispose()
end
end
dispose = effectScope(function()
effect(function()
local value = selector()
local previous = current
current = value
pauseTracking()
spawn(listener, value, previous, disposeSafe)
resumeTracking()
end)
end)
if disposed then
dispose()
end
return disposeSafe
end
return listen

View file

@ -1,71 +1,127 @@
local atom = require(script.Parent.atom)
local store = require(script.Parent.store)
local subscribe = require(script.Parent.subscribe)
local types = require(script.Parent.types)
type Atom<T> = types.Atom<T>
type Selector<T> = types.Selector<T>
local computed = require("./computed")
local signals = require("./signals")
type Map =
(<K0, V0, K1, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1)) -> Selector<{ [K1]: V1 }>)
& (<K0, V0, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Selector<{ [K0]: V1 }>)
& (<K0, V0, K1, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Selector<{ [K1]: V1 }>)
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
--[=[
Maps each entry in the atom's state to a new key-value pair. If the `mapper`
function returns `undefined`, the entry is omitted from the resulting map.
When the atom changes, the `mapper` is called for each entry in the state
to compute the new state.
type MappedKey<VI, KO, VO> = {
key: KO,
value: VO,
}
@param callback The atom or selector to map.
@param mapper The function that maps each entry.
@return A new atom with the mapped state.
]=]
local function mapped<K0, V0, K1, V1>(callback: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Selector<{ [K1]: V1 }>
local mappedAtom = atom({})
local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" })
local prevMappedItems: { [K1]: V1 } = {}
local unsubscribe
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 listener(items: { [K0]: V0 })
local mappedAtom = mappedAtomRef.current
local function getTransform(value: VI, key: KI): (VO, KO?)
pauseTracking()
local success, valueOut, keyOut = pcall(transform, value, key)
resumeTracking()
if not mappedAtom then
return unsubscribe()
if not success then
error(`observe() transform function failed: {valueOut}`)
end
local mappedItems = table.clone(mappedAtom())
local mappedKeys = {}
-- TODO: Only call mapper if the item has changed.
for key, item in next, items do
local newItem, newKey = mapper(item, key)
if newKey == nil then
newKey = key :: any
return valueOut, keyOut
end
if mappedItems[newKey :: K1] ~= newItem then
mappedItems[newKey :: K1] = newItem :: V1
else
mappedKeys[newKey] = key
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
for key in next, prevMappedItems do
if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then
mappedItems[key] = nil
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
prevMappedItems = mappedItems
mappedAtom(mappedItems)
end
previousItems = currentItems
unsubscribe = subscribe(callback, listener)
store.peek(function(): ()
listener(callback())
return mappedItems
end)
return mappedAtom
end
return mapped :: Map
return mapped

View file

@ -1,49 +1,37 @@
local store = require(script.Parent.store)
local subscribe = require(script.Parent.subscribe)
local types = require(script.Parent.types)
type Selector<T> = types.Selector<T>
local listen = require("./listen")
local function noop() end
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> ()
local cleanups: { [K]: () -> ()? } = {}
--[=[
Creates an instance of `factory` for each item in the atom's state, and
cleans up the instance when the item is removed. Returns a cleanup function
that unsubscribes all instances.
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
@param callback The atom or selector to observe.
@param factory The function that tracks the lifecycle of each item.
@return A function that unsubscribes all instances.
]=]
local function observe<K, V>(callback: Selector<{ [K]: V }>, factory: (value: V, key: K) -> (() -> ())?): () -> ()
local connections: { [K]: () -> () } = {}
local function listener(state: { [K]: V })
for key, disconnect in next, connections do
if state[key] == nil then
connections[key] = nil
disconnect()
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 next, state do
if not connections[key] then
connections[key] = factory(value, key) or noop
for key, value in currentItems do
if previousItems[key] == nil then
cleanups[key] = observer(value, key)
end
end
end
local unsubscribe = subscribe(callback, listener)
store.peek(function(): ()
listener(callback())
end)
return function()
unsubscribe()
for _, disconnect in next, connections do
disconnect()
unsub()
for _, cleanup in cleanups do
(cleanup :: () -> ())()
end
table.clear(connections)
table.clear(cleanups)
end
end

View file

@ -0,0 +1,400 @@
--[=[
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 = 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 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

@ -0,0 +1,36 @@
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,214 +0,0 @@
local types = require(script.Parent.types)
type Atom<T> = types.Atom<T>
type Set<T> = types.Set<T>
type WeakMap<K, V> = types.WeakMap<K, V>
local __DEV__ = _G.__DEV__
local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" })
local batched: Set<() -> ()> = {}
local batching = false
local capturing = {
stack = {} :: { Set<Atom<any>> },
index = 0,
}
--[=[
Calls the given function and returns the result. If the function yields or
throws an error, the thread is closed and an error is thrown. Regardless of
the outcome, the `finally` function is called to clean up any resources.
@param callback The function to run.
@param finally Cleanup logic to run before error handling.
@param ... Arguments to pass to the callback.
@return The result of the callback.
]=]
local function try<T, U...>(callback: (U...) -> T | any, finally: (() -> ())?, ...: U...): T
if __DEV__ then
local thread = coroutine.create(callback)
local success, result = coroutine.resume(thread, ...)
if finally then
finally()
end
if coroutine.status(thread) == "suspended" then
local source, line, name = debug.info(callback, "sln")
coroutine.close(thread)
error(
"Yielding is not allowed in atom functions. Consider wrapping this code in a Promise or task.defer instead."
.. `\nFunction defined at: {source}:{line}`
.. if name == "" then "" else ` function {name}`
)
elseif not success then
local source, line, name = debug.info(callback, "sln")
error(
"An error occurred while running an atom function"
.. `\nFunction defined at: {source}:{line}`
.. (if name == "" then "" else ` function {name}`)
.. `\nError: {result}`
)
end
return result
end
if not finally then
return callback(...)
end
local success, result = pcall(callback, ...)
finally()
assert(success, result)
return result
end
--[=[
Returns whether the given value is an atom.
@param value The value to check.
@return `true` if the value is an atom, otherwise `false`.
]=]
local function isAtom(value: any): boolean
return not not (value and listeners[value])
end
--[=[
Notifies all subscribers of the given atom that the state has changed.
@param atom The atom to notify.
]=]
local function notify(atom: Atom<any>)
if batching then
for listener in next, listeners[atom] do
batched[listener] = true
end
return
end
for listener in next, table.clone(listeners[atom]) do
try(listener)
end
end
--[=[
Returns the result of the function without subscribing to changes. If a
non-function value is provided, it is returned as is.
@param callback The atom or selector to get the state of.
@param args Arguments to pass to the function.
@return The current state.
]=]
local function peek<T, U...>(callback: ((U...) -> T) | T, ...: U...): T
if type(callback) ~= "function" then
return callback
end
if capturing.index == 0 then
return callback(...)
end
capturing.index += 1
capturing.stack[capturing.index] = {}
local result = try(callback, function()
capturing.stack[capturing.index] = nil
capturing.index -= 1
end, ...)
return result
end
--[=[
Captures all atoms that are read during the function call and returns them
along with the result of the function. Useful for tracking dependencies.
@param callback The function to run.
@return A tuple containing the captured atoms and the result of the function.
]=]
local function capture<T, U...>(callback: (U...) -> T, ...: U...): (Set<Atom<any>>, T)
-- If the callback is an atom, return it immediately
if listeners[callback :: any] then
return { [callback :: any] = true }, peek(callback)
end
local dependencies: Set<Atom<any>> = {}
capturing.index += 1
capturing.stack[capturing.index] = dependencies
local result = try(callback, function()
capturing.stack[capturing.index] = nil
capturing.index -= 1
end, ...)
return dependencies, result
end
--[=[
Runs the given function and schedules listeners to be notified only once
after the function has completed. Useful for batching multiple changes.
@param callback The function to run.
]=]
local function batch(callback: () -> ())
if batching then
return callback()
end
batching = true
try(callback, function()
batching = false
end)
for listener in next, batched do
try(listener)
end
table.clear(batched)
end
--[=[
Subscribes the listener to the changes of the given atoms.
@param atoms The atoms to listen to.
@param listener The function to call when the atoms change.
@param ref Optionally bind the lifetime of the listener to a value.
]=]
local function connect(atoms: Set<Atom<any>>, listener: () -> (), ref: unknown?)
for atom in next, atoms do
listeners[atom][listener] = ref or true
end
end
--[=[
Unsubscribes the listener from every atom it was connected to.
@param atoms The atoms to stop listening to.
@param listener The function to stop calling when the atoms change.
]=]
local function disconnect(atoms: Set<Atom<any>>, listener: () -> ())
for atom in next, atoms do
listeners[atom][listener] = nil
end
end
return {
listeners = listeners,
capturing = capturing,
isAtom = isAtom,
notify = notify,
capture = capture,
batch = batch,
peek = peek,
connect = connect,
disconnect = disconnect,
}

View file

@ -1,42 +1,36 @@
local store = require(script.Parent.store)
local types = require(script.Parent.types)
type Selector<T> = types.Selector<T>
local effectScope = require("./effectScope")
local signals = require("./signals")
local spawn = require("./spawn")
--[=[
Subscribes to changes in the given atom or selector. The callback is
called with the current state and the previous state immediately after a
change occurs.
local effect = signals.effect
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
@param callback The atom or selector to subscribe to.
@param listener The function to call when the state changes.
@return A function that unsubscribes the callback.
]=]
local function subscribe<T>(callback: Selector<T>, listener: (state: T, prev: T) -> ()): () -> ()
local dependencies, state = store.capture(callback)
local disconnected = false
local function subscribe<T>(selector: () -> T, listener: (T, T) -> ()): () -> ()
local current: T
local mount = true
local dispose: () -> ()
local function handler()
local prevState = state
dispose = effectScope(function()
effect(function()
local value = selector()
store.disconnect(dependencies, handler)
dependencies, state = store.capture(callback)
if mount then
current = value
mount = false
else
local previous = current
current = value
if not disconnected then
store.connect(dependencies, handler)
pauseTracking()
spawn(listener, value, previous)
resumeTracking()
end
if state ~= prevState then
listener(state, prevState)
end
end
store.connect(dependencies, handler)
end)
end)
return function()
if not disconnected then
disconnected = true
store.disconnect(dependencies, handler)
end
dispose()
end
end

View file

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

View file

@ -1,43 +1,9 @@
export type Set<T> = { [T]: true }
export type Selector<T = any> = () -> T
export type WeakMap<K, V> = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" }))
export type Atom<T = any> = ((((T) -> T) | T) -> T) & (() -> T)
--[=[
A primitive state container that can be read from and written to. When the
state changes, all subscribers are notified.
@param state The next state or a function that produces the next state.
@return The current state, if no arguments are provided.
]=]
export type Atom<T> = (state: (T | (T) -> T)?) -> T
--[=[
A function that depends on one or more atoms and produces a state. Can be
used to derive state from atoms.
@template State The type of the state.
@return The current state.
]=]
export type Selector<T> = () -> T
--[=[
Optional configuration for creating an atom.
]=]
export type AtomOptions<T> = {
--[=[
A function that determines whether the state has changed. By default,
a strict equality check (`===`) is used.
]=]
equals: (prev: T, next: T) -> boolean,
export type AtomOptions<T = any> = {
equals: ((prev: T, next: T) -> boolean)?,
}
--[=[
A payload that can be sent from the server to the client to synchronize
state between the two.
]=]
export type SyncPayload = {
type: "patch" | "init",
data: { [any]: any },
}
return nil
return {}

View file

@ -0,0 +1,18 @@
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,5 +1,3 @@
import { Selector } from "@rbxts/charm";
/**
* A hook that subscribes to changes in the given atom or selector. The
* component is re-rendered whenever the state changes.
@ -12,4 +10,4 @@ import { Selector } from "@rbxts/charm";
* @param dependencies An array of values that the subscription depends on.
* @returns The current state.
*/
export function useAtom<State>(callback: Selector<State>, dependencies?: unknown[]): State;
export function useAtom<State>(callback: () => State, dependencies?: unknown[]): State;

View file

@ -1,8 +1,12 @@
local Charm = require(script.Parent.Charm)
local React = if script.Parent:FindFirstChild("react")
then require(script.Parent.react) :: never
then require((script :: any).Parent.react) :: never
else 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.
@ -16,11 +20,10 @@ local React = if script.Parent:FindFirstChild("react")
@return The current state.
]=]
local function useAtom<State>(callback: Charm.Selector<State>, dependencies: { any }?): State
local state, setState = React.useState(callback)
local state, setState = useState(callback)
React.useEffect(function()
setState(callback())
return Charm.subscribe(callback, setState)
useEffect(function()
return listen(callback, setState)
end, dependencies or {})
return state

View file

@ -1,9 +1,7 @@
import { Selector } from "@rbxts/charm";
/**
* Subscribes to the state of an atom and returns a Vide source.
*
* @param callback The atom or selector to subscribe to.
* @returns The reactive source.
*/
export function useAtom<T>(callback: Selector<T>): () => T;
export function useAtom<T>(callback: () => T): () => T;

View file

@ -1,8 +1,12 @@
local Charm = require(script.Parent.Charm)
local Vide = if script.Parent:FindFirstChild("vide")
then require(script.Parent.vide.src) :: never
then require((script :: any).Parent.vide.src) :: never
else 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.
@ -10,13 +14,9 @@ local Vide = if script.Parent:FindFirstChild("vide")
@return The reactive source.
]=]
local function useAtom<State>(callback: () -> State): () -> State
local state = Vide.source(callback())
local unsubscribe = Charm.subscribe(callback, function(value)
task.spawn(state :: (State) -> State, value)
end)
Vide.cleanup(unsubscribe)
local state = source(callback())
local dispose = subscribe(callback, state)
cleanup(dispose)
return state
end

View file

@ -5,8 +5,9 @@
[tools]
wally = "UpliftGames/wally@0.3.2"
rojo = "rojo-rbx/rojo@7.4.4"
rojo = "rojo-rbx/rojo@7.5.1"
wally-package-types = "JohnnyMorganz/wally-package-types@1.4.2"
stylua = "johnnymorganz/stylua@2.0.2"
stylua = "johnnymorganz/stylua@2.1.0"
selene = "Kampfkarren/selene@0.28.0"
luau-lsp = "johnnymorganz/luau-lsp@1.38.1"
luau-lsp = "johnnymorganz/luau-lsp@1.44.1"
lute = "luau-lang/lute@0.1.0-nightly.20250503"

View file

@ -1,4 +1,4 @@
std = "roblox+testez"
std = "selene_defs"
[lints]
global_usage = "allow"

11
selene_defs.yml Normal file
View file

@ -0,0 +1,11 @@
base: roblox
name: selene_defs
globals:
require:
args:
- type: string
assert:
args:
- type: any
- type: any
required: false

View file

@ -1 +1,4 @@
syntax = "Luau"
[sort_requires]
enabled = true

View file

@ -1 +0,0 @@
roots = ["ServerScriptService/Tests"]

View file

@ -1,53 +0,0 @@
---
globals:
FIXME:
args:
- required: false
type: string
FOCUS:
args: []
SKIP:
args: []
afterAll:
args:
- type: function
afterEach:
args:
- type: function
beforeAll:
args:
- type: function
beforeEach:
args:
- type: function
describe:
args:
- type: string
- type: function
describeFOCUS:
args:
- type: string
- type: function
describeSKIP:
args:
- type: string
- type: function
expect:
args:
- type: any
it:
args:
- type: string
- type: function
itFIXME:
args:
- type: string
- type: function
itFOCUS:
args:
- type: string
- type: function
itSKIP:
args:
- type: string
- type: function

View file

@ -1,15 +1,25 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local computed = Charm.computed
local effect = Charm.effect
local atom = Charm.atom
local cleanup: () -> ()
return {
ParameterGenerator = function() end,
AfterEach = function()
cleanup()
end,
Functions = {
atom = function()
local state = atom(0)
cleanup = effect(function()
state()
end)
for _ = 1, 500 do
state(math.random(1, 2))
end
@ -18,10 +28,14 @@ return {
computed = function()
local state = atom(0)
local _double = computed(function()
local double = computed(function()
return 2 * state()
end)
cleanup = effect(function()
double()
end)
for _ = 1, 500 do
state(math.random(1, 2))
end

View file

@ -0,0 +1,38 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local signals = require(ReplicatedStorage.DevPackages.Charm.signals)
local function propagate(
signal: <T>(T) -> (T?) -> T,
computed: <T>(() -> T) -> () -> T,
effect: (() -> ()) -> (),
w: number,
h: number
)
local src = signal(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
effect(function()
last()
end)
end
src(src() + 1)
end
return {
Functions = {
charm = function()
propagate(Charm.atom, Charm.computed, Charm.effect, 10, 10)
end,
alien_signals = function()
propagate(signals.signal, signals.computed, signals.effect, 10, 10)
end,
},
}

View file

@ -0,0 +1,61 @@
local Charm = require("../../packages/charm/src/lib")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local signals = require("../../packages/charm/src/signals")
local signal = signals.signal
local computedSignal = signals.computed
local effectSignal = signals.effect
local w = 10
local h = 10
local function bench(name: string, test: () -> ())
local start = os.clock()
local iter = 0
for _ = 1, 15000 do
iter += 1
test()
end
local endTime = os.clock()
local elapsed = endTime - start
print(`{name}: {elapsed / iter * 1000 * 1000} us/iter`)
end
bench(`charm {w} * {h}`, function()
local src = atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
effect(function()
last()
end)
end
src(src() + 1)
end)
bench(`alien-signals {w} * {h}`, function()
local src = signal(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computedSignal(function()
return prev() + 1
end)
end
effectSignal(function()
last()
return nil
end)
end
src(src() + 1)
end)

View file

@ -1,121 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local subscribe = Charm.subscribe
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
local client = CharmSync.client
return function()
it("syncs state payload", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = 2 },
})
expect(a()).to.equal(2)
end)
it("syncs 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() },
})
expect(a()).to.equal(previous)
client:sync({
type = "patch",
data = { a = { b = 3 } },
})
expect(a()).to.never.equal(previous)
expect(a().b).to.equal(3)
expect(a().c).to.equal(2)
end)
it("fixes 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 } },
})
expect(a()["2" :: any]).to.never.be.ok()
expect(a()[2]).to.equal(2)
end)
it("applies 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 },
})
expect(a()).to.equal(3)
expect(calls[1]).to.equal(2)
expect(calls[2]).to.equal(3)
end)
it("batches 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 },
})
expect(a()).to.equal(1)
expect(b()).to.equal(2)
expect(states.a).to.equal(1)
expect(states.b).to.equal(2)
end)
end

View file

@ -1,24 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
local flatten = CharmSync.flatten
return function()
it("flattens atoms", function()
local input = {
a = atom(0),
b = { c = atom(0) },
d = { e = { f = atom(0) } },
}
local result = flatten(input)
expect(result.a).to.equal(input.a)
expect(result["b/c"]).to.equal(input.b.c)
expect(result["d/e/f"]).to.equal(input.d.e.f)
expect(result.b).to.never.be.ok()
expect(result.d).to.never.be.ok()
end)
end

View file

@ -1,121 +0,0 @@
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
return function()
local function serdes(value: { [any]: any })
return HttpService:JSONDecode(HttpService:JSONEncode(value))
end
it("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local patches = patch.diff(prevState, nextState)
expect(patches).to.be.ok()
expect(patches.baz).to.equal("quux")
expect(patches.foo).to.never.be.ok()
end)
it("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local patches = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, patches)
expect(applied).to.be.ok()
expect(applied.baz).to.equal("quux")
expect(applied.foo).to.equal("bar")
expect(applied.qaz).to.equal("qux")
end)
it("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)
expect(applied.foo.bar).to.equal("qux")
end)
it("preserves array density when applying", function()
local prevState = { "a", "b", "c", "d" }
local nextState = { ["2"] = "B", ["4"] = "D" }
local applied = patch.apply(prevState, nextState)
expect(applied[1]).to.equal("a")
expect(applied[2]).to.equal("B")
expect(applied[3]).to.equal("c")
expect(applied[4]).to.equal("D")
expect(applied["2"]).to.never.be.ok()
expect(applied["4"]).to.never.be.ok()
end)
it("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
expect(applied[index]).to.equal(nextState[index])
end
for index in next, applied do
expect(applied[index]).to.equal(nextState[index])
end
end)
it("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
expect(applied[index]).to.equal(nextState[index])
end
end)
it("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.
expect(applied["2"]).to.be.ok()
expect(applied[2]).to.never.be.ok()
end)
it("optionally fixes sparse arrays", function()
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do
expect(index).to.be.a("string")
end
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
expect(index).to.be.a("number")
end
end)
it("optionally validates state", function()
expect(function()
patch.diff({}, { [0.1] = true }, true)
end).to.throw()
expect(function()
patch.diff({}, { [0.1] = true }, false)
end).to.never.throw()
end)
end

View file

@ -1,97 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
type SyncPayload = CharmSync.SyncPayload
local server = CharmSync.server
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
return function()
local target: Player = {} :: any
it("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)
expect(player).to.equal(target)
expect(payload.type).to.equal("init")
expect(payload.data.a).to.equal(1)
disconnect()
end)
it("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)
expect(player).to.equal(target)
expect(payload.type).to.equal("patch")
expect(payload.data.a.b).to.never.be.ok()
expect(payload.data.a.c).to.equal(5)
a({ b = 1, d = 1 })
server:_sendPatch(target)
expect(payload.data.a.b).to.never.be.ok()
expect(patch.isNone(payload.data.a.c)).to.equal(true)
expect(payload.data.a.d).to.equal(1)
disconnect()
end)
it("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)
expect(#payloads).to.equal(1)
expect(payloads[1].type).to.equal("patch")
expect(payloads[1].data.a).to.equal(2)
expect(payloads[1].data.b).to.equal(2)
a(3)
a(4)
b(3)
server:_sendPatch(target)
expect(#payloads).to.equal(2)
expect(payloads[1].type).to.equal("patch")
expect(payloads[1].data.a).to.equal(3)
expect(payloads[1].data.b).to.never.be.ok()
expect(payloads[2].type).to.equal("patch")
expect(payloads[2].data.a).to.equal(4)
expect(payloads[2].data.b).to.equal(3)
disconnect()
end)
end

View file

@ -1,33 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate)
return function()
it("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)
end

View file

@ -1,62 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
it("updates state", function()
local source = atom(1)
source(2)
expect(source()).to.equal(2)
source(function(x)
return x + 1
end)
expect(source()).to.equal(3)
end)
it("calls listeners", function()
local source = atom(1)
local value
local function listener()
value = source()
end
store.listeners[source][listener] = true
source(2)
expect(value).to.equal(2)
end)
it("skips pending listeners", function()
local source = atom(1)
local outer = 0
local inner = 0
local function listener()
outer += 1
local function listener()
inner += 1
end
store.listeners[source][listener] = true
end
store.listeners[source][listener] = true
source(2)
expect(outer).to.equal(1)
expect(inner).to.equal(0)
source(3)
expect(outer).to.equal(2)
expect(inner).to.equal(1)
end)
it("takes a custom comparator", function()
local max = atom(1, {
equals = function(prev, next)
return next < prev
end,
})
max(2)
expect(max()).to.equal(2)
max(1)
expect(max()).to.equal(2)
max(3)
expect(max()).to.equal(3)
end)
end

View file

@ -1,64 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local computed = Charm.computed
local subscribe = Charm.subscribe
local effect = Charm.effect
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
it("batches updates", function()
local source = atom(1)
local value = 0
subscribe(source, function()
value += 1
end)
store.batch(function()
source(2)
source(3)
source(4)
end)
expect(value).to.equal(1)
end)
it("handles duplicate listeners", function()
local a = atom(1)
local b = atom(1)
local value = 0
effect(function()
value += 1
a()
b()
end)
value = 0 -- effect() will run once initially
store.batch(function()
a(2)
b(2)
a(3)
end)
expect(value).to.equal(1)
end)
it("batches computed atoms", function()
local source = atom(1)
local updates = 0
local computations = 0
local double = computed(function()
computations += 1
return source() * 2
end)
computations = 0 -- computed atoms will run once initially
subscribe(double, function()
updates += 1
end)
store.batch(function()
source(2)
source(3)
source(4)
end)
expect(updates).to.equal(1)
expect(computations).to.equal(1)
expect(double()).to.equal(8)
end)
end

View file

@ -1,64 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local subscribe = Charm.subscribe
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
it("gracefully handles yielding", function()
expect(function()
store.capture(function()
task.wait(10)
return 1
end)
end).to.throw()
end)
it("captures atoms from a function", function()
local a, b, c = atom(1), atom(1), atom(2)
local deps, result = store.capture(function()
return a() + b()
end)
expect(deps[a]).to.be.ok()
expect(deps[b]).to.be.ok()
expect(deps[c]).to.never.be.ok()
expect(result).to.equal(2)
end)
it("captures an atom by itself", function()
local a = atom(1)
local deps, result = store.capture(a)
expect(deps[a]).to.be.ok()
expect(result).to.equal(1)
end)
it("skips atoms in inner capture calls", function()
local a = atom(1)
local b = atom(1)
local inner
local outer = store.capture(function()
inner = store.capture(function()
return b()
end)
return a()
end)
assert(outer[a], "outer did not capture a")
assert(outer[b] == nil, "outer captured b")
assert(inner[a] == nil, "inner captured a")
assert(inner[b], "inner did not capture b")
end)
it("should not capture notified dependents", function()
local state = atom(1)
subscribe(state, function() end)
local deps = store.capture(function()
return state(2)
end)
expect(deps[state]).to.never.be.ok()
end)
end

View file

@ -1,105 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local computed = Charm.computed
local store = require(ReplicatedStorage.DevPackages.Charm.store)
local collect = require(script.Parent.Parent.utils.collect)
local count = require(script.Parent.Parent.utils.count)
return function()
it("memoizes the result", function()
local calls = 0
local a = atom(1)
local b = atom(2)
local double = computed(function()
calls += 1
return a() * b()
end)
expect(calls).to.equal(1)
a(1)
expect(calls).to.equal(1)
a(2)
expect(calls).to.equal(2)
b(3)
expect(calls).to.equal(3)
double()
expect(calls).to.equal(3)
end)
it("can be nested", function()
local source = atom(1)
local double = computed(function()
return source() * 2
end)
local quadruple = computed(function()
return double() * 2
end)
expect(quadruple()).to.equal(4)
source(2)
expect(quadruple()).to.equal(8)
end)
it("collects computed atoms", function()
local source = atom(0)
computed(function()
return source()
end)
collect()
assert(count(store.listeners[source]) == 0, "computed atom did not gc")
end)
it("collects nested computed atoms", function()
local source = atom(0)
do
-- Declare this in another scope to ensure no references exist at the
-- time of garbage collection.
local double = computed(function()
return source() * 2
end)
computed(function()
return double() * 2
end)
end
collect()
assert(count(store.listeners[source]) == 0, "nested computed atom did not gc")
end)
it("tracks conditional dependencies", function()
local condition = atom(false)
local a = atom(0)
local b = atom(1)
local reruns = 0
local current
-- assign variable to avoid gc
local _ = computed(function()
reruns += 1
current = if condition() then a() else b()
return current
end)
expect(reruns).to.equal(1)
expect(current).to.equal(1)
condition(true)
expect(reruns).to.equal(2)
expect(current).to.equal(0)
a(1)
b(2)
expect(reruns).to.equal(3)
expect(current).to.equal(1)
condition(false)
expect(reruns).to.equal(4)
expect(current).to.equal(2)
end)
end

View file

@ -1,111 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local effect = Charm.effect
return function()
it("runs on state change", function()
local a = atom(0)
local b = atom(0)
local current
local unsubscribe = effect(function()
current = a() + b()
end)
expect(current).to.equal(0)
for index = 1, 3 do
a(index)
expect(current).to.equal(index)
end
for index = 1, 3 do
b(index)
expect(current).to.equal(a() + index)
end
unsubscribe()
a(4)
expect(current).to.equal(6)
end)
it("accepts a cleanup function", function()
local a = atom(0)
local cleanups = 0
local unsubscribe = effect(function()
a()
return function()
cleanups += 1
end
end)
expect(cleanups).to.equal(0)
a(1)
expect(cleanups).to.equal(1)
unsubscribe()
expect(cleanups).to.equal(2)
end)
it("tracks conditional dependencies", function()
local condition = atom(false)
local a = atom(0)
local b = atom(1)
local reruns = 0
local current
effect(function()
reruns += 1
current = if condition() then a() else b()
end)
expect(reruns).to.equal(1)
expect(current).to.equal(1)
condition(true)
expect(reruns).to.equal(2)
expect(current).to.equal(0)
a(1)
b(2)
expect(reruns).to.equal(3)
expect(current).to.equal(1)
condition(false)
expect(reruns).to.equal(4)
expect(current).to.equal(2)
end)
it("allows self-cleanup", function()
local a = atom(1)
local cleanedUp = false
local cleanup
cleanup = effect(function()
if cleanup then
assert(not cleanedUp, "cleanup ran twice")
cleanup()
cleanedUp = true
end
a()
end)
a(2)
a(3)
end)
it("allows immediate self-cleanup", function()
local a = atom(1)
local cleanedUp = false
effect(function(cleanup)
assert(not cleanedUp, "cleanup ran twice")
cleanup()
cleanedUp = true
a()
end)
a(2)
a(3)
end)
end

View file

@ -1,97 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local mapped = Charm.mapped
local observe = Charm.observe
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
it("returns mapped atoms", function()
local itemsAtom = atom({ "a", "b", "c" })
local mappedAtom = mapped(itemsAtom, function(value, key)
return string.upper(value), key
end)
local updates = 0
store.listeners[mappedAtom][function()
updates += 1
end] = true
for key = 1, 3 do
local item = mappedAtom()[key]
expect(item).to.equal(string.upper(itemsAtom()[key]))
end
itemsAtom({ "d", "e", "f" })
expect(updates).to.equal(1)
for key = 1, 3 do
local item = mappedAtom()[key]
expect(item).to.equal(string.upper(itemsAtom()[key]))
end
itemsAtom({ [1] = "d", [3] = "f" })
expect(mappedAtom()[2]).to.never.be.ok()
expect(updates).to.equal(2)
end)
it("uses key extractor", function()
local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } })
local mappedAtom = mapped(itemsAtom, function(item)
return item, item.key
end)
for key = 1, 3 do
local item = mappedAtom()[key]
expect(item).to.equal(itemsAtom()[key])
end
local previous = mappedAtom()
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
expect(mappedAtom()[1]).to.equal(previous[1])
expect(mappedAtom()[1]).to.equal(itemsAtom()[1])
expect(mappedAtom()[2]).to.never.be.ok()
expect(mappedAtom()[3]).to.equal(previous[3])
expect(mappedAtom()[3]).to.equal(itemsAtom()[2])
end)
it("allows observe()", function()
local itemsAtom = atom({
{ key = 1, value = "a" },
{ key = 2, value = "b" },
{ key = 3, value = "c" },
})
local mappedAtom = mapped(itemsAtom, function(item)
return item, item.key
end)
local added = 0
local deleted = 0
observe(mappedAtom, function(item, key)
expect(item).to.equal(itemsAtom()[key])
added += 1
return function()
expect(mappedAtom()[key]).to.never.be.ok()
deleted += 1
end
end)
expect(added).to.equal(3)
expect(deleted).to.equal(0)
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
expect(added).to.equal(3)
expect(deleted).to.equal(1)
itemsAtom({})
expect(added).to.equal(3)
expect(deleted).to.equal(3)
end)
end

View file

@ -1,35 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local observe = Charm.observe
return function()
it("observes lifetime", function()
local itemsAtom = atom({ "a", "b", "c" })
local state = {}
local cleanup = observe(itemsAtom, function(item, key)
state[key] = item
return function()
state[key] = nil
end
end)
for key = 1, 3 do
expect(state[key]).to.equal(itemsAtom()[key])
end
itemsAtom({ [4] = "d", [5] = "e", [6] = "f" })
for key = 1, 6 do
expect(state[key]).to.equal(itemsAtom()[key])
end
cleanup()
for key = 1, 6 do
expect(state[key]).to.equal(nil)
end
end)
end

View file

@ -1,26 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local capture = Charm.capture
local peek = Charm.peek
return function()
it("calls an atom without capturing it", function()
local a, b, c = atom(1), atom(1), atom(1)
local deps, result = capture(function()
return a() + peek(b) + c()
end)
expect(deps[a]).to.be.ok()
expect(deps[b]).to.never.be.ok()
expect(deps[c]).to.be.ok()
expect(result).to.equal(3)
end)
it("passes through arguments", function()
expect(peek(1)).to.equal(1)
expect(peek(function()
return 1
end)).to.equal(1)
end)
end

View file

@ -1,101 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local computed = Charm.computed
local subscribe = Charm.subscribe
local store = require(ReplicatedStorage.DevPackages.Charm.store)
local collect = require(script.Parent.Parent.utils.collect)
local count = require(script.Parent.Parent.utils.count)
return function()
it("runs on state change", function()
local current, previous
local source = atom(0)
local unsubscribe = subscribe(source, function(...)
current, previous = ...
end)
for index = 1, 3 do
source(index)
expect(current).to.equal(index)
expect(previous).to.equal(index - 1)
end
unsubscribe()
source(4)
expect(current).to.equal(3)
expect(previous).to.equal(2)
end)
it("prevents garbage collect", function()
local source = atom(1)
local updates = 0
-- Create a new scope to encapsulate variables with a limited lifetime, like
-- 'unsubscribe' and 'computed', to ensure they are garbage collected.
--
-- The idea here is to test whether subscribe() prevents every computed
-- atom in a chain from being garbage collected. If any are GCed, then
-- the subscription will stop updating and the test will fail.
do
local unsubscribe
do
local double = computed(function()
return source() * 2
end)
unsubscribe = subscribe(
computed(function()
return double() * 2
end),
function()
updates += 1
end
)
end
collect()
source(source() + 1)
assert(updates == 1, "subscription stopped updating")
assert(count(store.listeners[source]) == 1, "computed atom was garbage collected")
unsubscribe()
end
collect()
assert(count(store.listeners[source]) == 0, "computed atom did not collect")
end)
it("tracks conditional dependencies", function()
local condition = atom(false)
local a = atom(0)
local b = atom(1)
local reruns = 0
local current
subscribe(function()
return if condition() then a() else b()
end, function(state)
reruns += 1
current = state
end)
condition(true)
expect(reruns).to.equal(1)
expect(current).to.equal(0)
a(1)
b(2)
expect(reruns).to.equal(2)
expect(current).to.equal(1)
condition(false)
expect(reruns).to.equal(3)
expect(current).to.equal(2)
end)
end

View file

@ -1,13 +0,0 @@
--!nocheck
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ)
-- Start Charm in debug mode
_G.__DEV__ = true
require(ReplicatedStorage.DevPackages.Charm)
require(ReplicatedStorage.DevPackages.CharmSync)
_G.__DEV__ = false
TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter)

View file

@ -1,11 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
afterEach(function()
table.clear(store.listeners :: any)
table.clear(store.capturing.stack)
store.capturing.index = 0
end)
end

13
tests/react/spec.luau Normal file
View file

@ -0,0 +1,13 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("useAtom")
return {}

View file

@ -1,46 +1,48 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local ReactCharm = require(ReplicatedStorage.DevPackages.ReactCharm)
local useAtom = ReactCharm.useAtom
local renderHook = require(script.Parent.Parent.utils.renderHook)
local suite = require(ServerScriptService.Tests.suite)
return function()
it("tracks atom state", function()
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)
expect(hook.result).to.equal(0)
assert(hook.result == 0)
state(1)
expect(hook.result).to.equal(1)
end)
assert(hook.result == 1)
end)
it("accepts a selector", function()
test("accepts a selector", function()
local state = atom(0)
local hook = renderHook(function()
return useAtom(function()
return 2 * state()
end)
end)
expect(hook.result).to.equal(0)
assert(hook.result == 0)
state(1)
expect(hook.result).to.equal(2)
end)
assert(hook.result == 2)
end)
it("tracks dependencies", function()
test("tracks dependencies", function()
local state = atom(0)
local hook = renderHook(function()
return useAtom(state, { state })
end)
expect(hook.result).to.equal(0)
assert(hook.result == 0)
state(1)
expect(hook.result).to.equal(1)
assert(hook.result == 1)
state = atom(0)
hook.rerender()
expect(hook.result).to.equal(0)
assert(hook.result == 0)
state(1)
expect(hook.result).to.equal(1)
end)
end
assert(hook.result == 1)
end)

View file

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

View file

@ -0,0 +1,337 @@
local test = require("../suite").test
local signals = require("../../packages/charm/src/signals")
local computed = signals.computed
local effect = signals.effect
local effectScope = signals.effectScope
local endBatch = signals.endBatch
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local signal = signals.signal
local startBatch = signals.startBatch
test("should clear subscriptions when untracked by all subscribers", function()
local bRunTimes = 0
local a = signal(1)
local b = computed(function()
bRunTimes += 1
return a() * 2
end)
local stopEffect = effect(function()
b()
end)
assert(bRunTimes == 1, "should run initially")
a(2)
assert(bRunTimes == 2, "should run after change")
stopEffect()
a(3)
assert(bRunTimes == 2, "should not run after stop")
end)
test("should not run untracked inner effect", function()
local a = 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)
a(2)
a(1)
a(0)
end)
test("should run outer effect first", function()
local a = signal(1)
local b = signal(1)
effect(function()
if a() > 0 then
effect(function()
b()
if a() == 0 then
error("bad")
end
end)
end
end)
startBatch()
b(0)
a(0)
endBatch()
end)
test("should not trigger inner effect when resolve maybe dirty", function()
local a = 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)
a(2)
end)
test("should trigger inner effects in sequence", function()
local a = signal(0)
local b = 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()
b(1)
a(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 = signal(0)
local b = 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()
b(1)
a(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 = signal(0)
local aa = computed(function()
table.insert(logs, "aa-0")
if a() == 0 then
b(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 = signal(0)
local src2 = signal(0)
local order: { string } = {}
effect(function()
table.insert(order, "a")
pauseTracking()
local isOne = src2() == 1
resumeTracking()
if isOne then
src1()
end
src2()
src1()
end)
effect(function()
table.insert(order, "b")
src1()
end)
src2(1) -- src1.subs: a -> b -> a
table.clear(order)
src1(src1() + 1)
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
end)
test("should handle side effect with inner effects", function()
local a = signal(0)
local b = 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)
b(1)
a(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 = 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")
a(true)
assert(triggers == 2, "did not update indirectly")
end)
test("should run effect cleanup", function()
local cleanups = 0
local a = signal(false)
local cleanup = effect(function()
a()
return function()
cleanups += 1
end
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup once")
cleanup()
assert(cleanups == 2, "should cleanup after effect stop")
a(false)
assert(cleanups == 2, "should not cleanup after update")
end)
test("should run inner effect cleanup in scope", function()
local cleanups = 0
local a = signal(false)
local stopScope = effectScope(function()
effect(function()
a()
return function()
cleanups += 1
end
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup once")
stopScope()
assert(cleanups == 2, "should cleanup after outer dispose")
a(false)
assert(cleanups == 2, "should not cleanup after update")
end)
test("should run inner effect cleanup", function()
local cleanups = 0
local a = signal(false)
local stopScope = effect(function()
a()
effect(function()
return function()
cleanups += 1
end
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup after outer update")
stopScope()
assert(cleanups == 2, "should cleanup after outer dispose")
a(false)
assert(cleanups == 2, "should not cleanup after update")
end)
return {}

View file

@ -0,0 +1,66 @@
local test = require("../suite").test
local signals = require("../../packages/charm/src/signals")
local effect = signals.effect
local effectScope = signals.effectScope
local signal = signals.signal
test("should not trigger after stop", function()
local count = signal(1)
local triggers = 0
local stopScope = effectScope(function()
effect(function()
triggers += 1
count()
end)
assert(triggers == 1, "did not run initially")
count(2)
assert(triggers == 2, "did not run after update")
end)
count(3)
assert(triggers == 3, "did not run after update outside scope")
stopScope()
count(4)
assert(triggers == 3, "ran after stop")
end)
test("should run effect cleanup", function()
local cleanups = 0
local cleanup = effectScope(function()
return function()
cleanups += 1
end
end)
assert(cleanups == 0, "should not cleanup immediately")
cleanup()
assert(cleanups == 1, "should cleanup after effect stop")
cleanup()
assert(cleanups == 1, "should skip redundant cleanup")
end)
test("should not run indirect effect cleanup", function()
local cleanups = 0
local cleanup
local stopScope = effectScope(function()
cleanup = effectScope(function()
return function()
cleanups += 1
end
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
stopScope()
assert(cleanups == 0, "should be detached from parent dispose")
cleanup()
assert(cleanups == 1, "should cleanup after inner dispose")
end)
return {}

View file

@ -0,0 +1,113 @@
local test = require("../suite").test
local signals = require("../../packages/charm/src/signals")
local computed = signals.computed
local effect = signals.effect
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local signal = signals.signal
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
pauseTracking()
local success, result = pcall(callback)
resumeTracking()
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 = 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)
source(1)
source(2)
source(3)
end)
return {}

18
tests/signals/spec.luau Normal file
View file

@ -0,0 +1,18 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("computed")
test("effect")
test("effectScope")
test("issue_48")
test("topology")
test("untrack")
return {}

View file

@ -0,0 +1,493 @@
local signals = require("../../packages/charm/src/signals")
local suite = require("../suite")
local test = suite.test
local describe = suite.describe
local computed = signals.computed
local effect = signals.effect
local signal = signals.signal
--[[
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 = 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
a(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 = 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")
a("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 = 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")
a("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 = 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")
a("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 = 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
a("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
a("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 = 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")
a("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 = 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()
a("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 = 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
a("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 = 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
a("aa")
e()
assert(spyResult == "aa c d", "e should have returned 'aa c d'")
end)
test("should support lazy branches", function()
local a = 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")
a(1)
assert(c() == 1, "c should be 1")
a(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 = 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
a("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 = 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")
a(1)
assert(c() == 1, "c should recover and return 1")
end)
test("should keep graph consistent on errors in computeds", function()
local a = 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")
a(1)
local success, result = pcall(function()
b()
end)
assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail")
a(2)
assert(c() == 2, "c should recover and return 2")
end)
end)
return {}

View file

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

20
tests/spec.server.luau Normal file
View file

@ -0,0 +1,20 @@
local suite = require("./suite")
local describe = suite.describe
local finish = suite.finish
local function test(name: string)
describe(name, function()
return require(`./{name}/spec`) :: any
end)
end
test("signals")
if game then
test("sync")
test("react")
test("vide")
end
finish()

64
tests/suite.luau Normal file
View file

@ -0,0 +1,64 @@
local results: { string } = {}
local stack: { string } = {}
local errors: { string } = {}
local tests = 0
local function log(result: string, index: number?)
local index = index or #results + 1
local indent = string.rep(" ", #stack - 1)
results[index] = indent .. result
end
local function test(name: string, callback: () -> ())
tests += 1
table.insert(stack, name)
local success = xpcall(callback, function(err)
local path = table.concat(stack, " > ")
table.insert(errors, `\n🔴 {path}:\n{err}`)
end)
if not success then
log(`🔴 {name}`)
end
table.remove(stack)
end
local function describe(name: string, callback: () -> ())
table.insert(stack, name)
table.insert(results, name)
local index = #results
local errorCount = #errors
callback()
if #errors > errorCount then
log(`🔴 {name}`, index)
else
log(`🟢 {name}`, index)
end
table.remove(stack)
end
local function finish()
print(`\n🔵 {tests - #errors} / {tests} tests passed\n`)
for _, result in results do
print(result)
end
print()
if #errors > 0 then
error(`{#errors} test(s) failed:\n{table.concat(errors, "\n")}\n`)
end
end
return {
test = test,
describe = describe,
finish = finish,
}

125
tests/sync/client.spec.luau Normal file
View file

@ -0,0 +1,125 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local CharmSync = require(ReplicatedStorage.DevPackages.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)
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)
client:sync({
type = "patch",
data = { a = { b = 3 } },
})
assert(a() ~= previous)
assert(a().b == 3)
assert(a().c == 2)
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)
assert(a()[2] == 2)
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)
assert(calls[1] == 2)
assert(calls[2] == 3)
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)
assert(b() == 2)
assert(states.a == 1)
assert(states.b == 2)
end)
return {}

View file

@ -0,0 +1,28 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local CharmSync = require(ReplicatedStorage.DevPackages.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)
assert(result["b/c"] == input.b.c)
assert(result["d/e/f"] == input.d.e.f)
assert(result.b == nil)
assert(result.d == nil)
end)
return {}

130
tests/sync/patch.spec.luau Normal file
View file

@ -0,0 +1,130 @@
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
local suite = require(ServerScriptService.Tests.suite)
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)
assert(patches.baz == "quux")
assert(patches.foo == nil)
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)
assert(applied.baz == "quux")
assert(applied.foo == "bar")
assert(applied.qaz == "qux")
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")
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")
assert(applied[2] == "B")
assert(applied[3] == "c")
assert(applied[4] == "D")
assert(applied["2"] == nil)
assert(applied["4"] == nil)
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])
end
for index in next, applied do
assert(applied[index] == 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])
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)
assert(applied[2] == nil)
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")
end
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
assert(type(index) == "number")
end
end)
test("optionally validates state", function()
local success = pcall(function()
patch.diff({}, { [0.1] = true }, true)
end)
assert(not success)
success = pcall(function()
patch.diff({}, { [0.1] = true }, false)
end)
assert(success)
assert(success)
end)
return {}

100
tests/sync/server.spec.luau Normal file
View file

@ -0,0 +1,100 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
local suite = require(ServerScriptService.Tests.suite)
type SyncPayload = CharmSync.SyncPayload
local atom = Charm.atom
local server = CharmSync.server
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)
assert(payload.type == "init")
assert(payload.data.a == 1)
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)
assert(payload.type == "patch")
assert(payload.data.a.b == nil)
assert(payload.data.a.c == 5)
a({ b = 1, d = 1 })
server:_sendPatch(target)
assert(payload.data.a.b == nil)
assert(patch.isNone(payload.data.a.c) == true)
assert(payload.data.a.d == 1)
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)
assert(payloads[1].type == "patch")
assert(payloads[1].data.a == 2)
assert(payloads[1].data.b == 2)
a(3)
a(4)
b(3)
server:_sendPatch(target)
assert(#payloads == 2)
assert(payloads[1].type == "patch")
assert(payloads[1].data.a == 3)
assert(payloads[1].data.b == nil)
assert(payloads[2].type == "patch")
assert(payloads[2].data.a == 4)
assert(payloads[2].data.b == 3)
disconnect()
end)

17
tests/sync/spec.luau Normal file
View file

@ -0,0 +1,17 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("client")
test("server")
test("flatten")
test("patch")
test("validate")
return {}

View file

@ -0,0 +1,35 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local suite = require(ServerScriptService.Tests.suite)
local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate)
local atom = Charm.atom
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)

13
tests/vide/spec.luau Normal file
View file

@ -0,0 +1,13 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("useAtom")
return {}

View file

@ -1,26 +1,29 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local Vide = require(ReplicatedStorage.DevPackages.Vide)
local root = Vide.root
local VideCharm = require(ReplicatedStorage.DevPackages.VideCharm)
local useAtom = VideCharm.useAtom
local suite = require(ServerScriptService.Tests.suite)
return function()
it("tracks atom state", function()
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)
expect(source()).to.equal(0)
assert(source() == 0)
state(1)
expect(source()).to.equal(1)
assert(source() == 1)
cleanup()
end)
end)
it("accepts a selector", function()
test("accepts a selector", function()
local state = atom(0)
local source
local cleanup = root(function()
@ -28,9 +31,10 @@ return function()
return 2 * state()
end)
end)
expect(source()).to.equal(0)
assert(source() == 0)
state(1)
expect(source()).to.equal(2)
assert(source() == 2)
cleanup()
end)
end
end)
return {}