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

@ -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.
@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 equals = options and options.equals
local signal = signals.signal
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local function atom(...)
if select("#", ...) == 0 then
local index = store.capturing.index
if index > 0 then
store.capturing.stack[index][atom] = true
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
local function atomOper<T>(source: (...T) -> T, equals: ((T, T) -> boolean)?, ...: ((T) -> T) | T): T
if select("#", ...) == 0 then
return source()
end
store.listeners[atom] = setmetatable({}, { __mode = "v" })
local transform = (...)
local currentState = (source :: any).value
local nextState: T
return atom
-- If the transform is a function, call it with the current state and pause
-- tracking to avoid adding signals called by the transform.
if type(transform) == "function" then
pauseTracking()
local success, result = pcall(transform, currentState)
resumeTracking()
if not success then
error(result, 2)
end
nextState = result
else
nextState = transform
end
if equals and equals(currentState, nextState) then
return nextState
end
source(nextState)
return nextState
end
local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
local source = signal(state)
local equals = options and options.equals
return function(...)
return atomOper(source, equals, ...)
end
end
return atom

View file

@ -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)
end
return function()
return getter()
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.
@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 effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> ()
local disposed = false
local dispose: () -> ()
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
end
dispose = effectScope(function()
effectImpl(function()
return callback(disposeSafe)
end)
end)
disconnected = true
store.disconnect(dependencies, listener)
if cleanup then
cleanup()
end
if disposed then
dispose()
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.
@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
type MappedKey<VI, KO, VO> = {
key: KO,
value: VO,
}
local function listener(items: { [K0]: V0 })
local mappedAtom = mappedAtomRef.current
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 }?
if not mappedAtom then
return unsubscribe()
local function getTransform(value: VI, key: KI): (VO, KO?)
pauseTracking()
local success, valueOut, keyOut = pcall(transform, value, key)
resumeTracking()
if not success then
error(`observe() transform function failed: {valueOut}`)
end
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
end
if mappedItems[newKey :: K1] ~= newItem then
mappedItems[newKey :: K1] = newItem :: V1
else
mappedKeys[newKey] = key
end
end
for key in next, prevMappedItems do
if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then
mappedItems[key] = nil
end
end
prevMappedItems = mappedItems
mappedAtom(mappedItems)
return valueOut, keyOut
end
unsubscribe = subscribe(callback, listener)
return computed(function(previousMappedItems)
local currentItems = selector()
store.peek(function(): ()
listener(callback())
-- If this is the initial run, create a map of the items and store the
-- updated keys and values.
if not previousItems or not previousMappedItems then
local mappedItems: { [KO]: VO } = {}
previousItems = currentItems
for key, value in currentItems do
local valueOut, keyOut = getTransform(value, key)
local keyOut = if keyOut ~= nil then keyOut else key :: never
if valueOut ~= nil then
mappedKeys[key] = { key = keyOut, value = valueOut }
mappedItems[keyOut] = valueOut
end
end
return mappedItems
end
local mappedItems = table.clone(previousMappedItems)
-- Update and remove mapped keys if the source values changed
for key, oldValue in previousItems do
local currentValue = currentItems[key]
local mappedKey = mappedKeys[key]
-- Remove the key if the source value changed from non-nil to nil
if currentValue == nil then
mappedKeys[key] = nil
if mappedKey then
mappedItems[mappedKey.key] = nil
end
continue
end
-- Do not call transform if the value has not changed
if currentValue == oldValue then
continue
end
local valueOut, keyOut = getTransform(currentValue, key)
local keyOut = if keyOut ~= nil then keyOut else key :: never
-- If there is no previous mappedKey, then this item was filtered
-- out to optimize memory. We need to check if the value is nil to
-- determine if we should add it back to the list.
if not mappedKey then
if valueOut ~= nil then
mappedKeys[key] = { key = keyOut, value = valueOut }
mappedItems[keyOut] = valueOut
end
continue
end
-- If the mapped value is nil, then delete mappedKey to save memory.
-- This is treated the same as setting mappedKey.value to nil.
if valueOut == nil then
mappedKeys[key] = nil
mappedItems[mappedKey.key] = nil
continue
end
-- If the key changed since the last transform call, remove
-- the old key and update mappedKey.
if keyOut ~= mappedKey.key then
mappedItems[mappedKey.key] = nil
mappedKey.key = keyOut
end
mappedKey.value = valueOut
mappedItems[keyOut] = valueOut
end
-- Add new keys if the value was not present in the previous state
for key, currentValue in currentItems do
local oldValue = previousItems[key]
if oldValue ~= nil then
continue
end
local valueOut, keyOut = getTransform(currentValue, key)
local keyOut = if keyOut ~= nil then keyOut else key :: never
if valueOut ~= nil then
mappedKeys[key] = { key = keyOut, value = valueOut }
mappedItems[keyOut] = valueOut
end
end
previousItems = currentItems
return mappedItems
end)
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.
@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 unsub = listen(selector, function(currentItems, previousItems)
if not previousItems then
for key, value in currentItems do
cleanups[key] = observer(value, key)
end
return
end
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.
@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 effect = signals.effect
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local function handler()
local prevState = state
local function subscribe<T>(selector: () -> T, listener: (T, T) -> ()): () -> ()
local current: T
local mount = true
local dispose: () -> ()
store.disconnect(dependencies, handler)
dependencies, state = store.capture(callback)
dispose = effectScope(function()
effect(function()
local value = selector()
if not disconnected then
store.connect(dependencies, handler)
end
if mount then
current = value
mount = false
else
local previous = current
current = value
if state ~= prevState then
listener(state, prevState)
end
end
store.connect(dependencies, handler)
pauseTracking()
spawn(listener, value, previous)
resumeTracking()
end
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