feat: add peek and roblox-ts types

This commit is contained in:
littensy 2025-07-02 21:58:55 -07:00
parent d2979ebd4c
commit 708bc55de7
37 changed files with 363 additions and 1234 deletions

View file

@ -1,24 +1,23 @@
local suite = require("../../../../test/suite")
suite.describe("charm-sync", function()
if not game then
return suite.skip()
end
suite.describe("client", function()
require("./client.spec")
end)
suite.describe("server", function()
require("./server.spec")
end)
suite.describe("flatten", function()
require("./flatten.spec")
end)
suite.describe("patch", function()
require("./patch.spec")
end)
suite.describe("validate", function()
require("./validate.spec")
end)
end)
return {}

View file

@ -1,6 +1,8 @@
local Charm = require("../Charm")
local validate = require("./validate")
local flags = Charm.flags
--- A special symbol that denotes the absence of a value. Used to represent
--- deleted values in patches.
local None = { __none = "__none" }
@ -63,7 +65,7 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial
patches = stringifySparseArray(patches)
end
if serialize and Charm.strict.enabled then
if serialize and flags.strict then
for key, value in next, patches do
validate(value, key)
end

View file

@ -23,13 +23,13 @@ local function gc()
end
end
--- In alien-core, a computed value unlinks from dependencies when its last
--- In alien-signals, a computed value unlinks from dependencies when its last
--- parent scope is disposed. Calling a computed value outside of a scope links
--- it to dependencies, but the dependencies hold strong references to the
--- computed value, so it won't GC until called in a scope and disposed.
---
--- This function releases a computed value by linking it to a scope and then
--- immediately closing it, causing alien-core to unlink the computed value
--- immediately closing it, causing alien-signals to unlink the computed value
--- and allow it to be garbage collected.
local function release(getter: () -> ())
effectScope(function()

View file

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

View file

@ -24,7 +24,6 @@ suite.describe("core", function()
end)
end)
suite.describe("charm", function()
suite.describe("listen", function()
require("./listen.spec")
end)
@ -40,6 +39,5 @@ suite.describe("charm", function()
suite.describe("untracked", function()
require("./untracked.spec")
end)
end)
return {}

View file

@ -5,8 +5,29 @@ local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local untracked = Charm.untracked
local onCleanup = Charm.onCleanup
local test = suite.test
test("should run detached", function()
local a = atom(1)
local spyCalls = 0
effect(function()
a()
untracked(function()
onCleanup(function()
spyCalls += 1
end, true)
end)
end)
assert(spyCalls == 0, "should not be called")
a(2)
assert(spyCalls == 0, "rerun should not call cleanup")
a(3)
assert(spyCalls == 0, "rerun should not call cleanup")
end)
test("should block tracking inside effects", function()
local a = atom(1)
local b = atom(2)

View file

@ -1,32 +1,4 @@
--[[
alien-signals 2.0.5 implementation adopted from
https://github.com/Nicell/alien-signals-luau
https://github.com/stackblitz/alien-signals
MIT License
Copyright (c) 2024-present Johnson Chu, Nicholas Winans
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]
local strict = require("./strict")
local config = require("./flags")
local system = require("./system")
local utils = require("./utils")
@ -103,15 +75,15 @@ local function setCurrentScope(scope: Effect | EffectScope?)
end
local function setCurrentTracking(tracking: boolean): boolean
local prevTracking = activeSubTracking
local prevSubTracking = activeSubTracking
activeSubTracking = tracking
return prevTracking
return prevSubTracking
end
local function runCleanups(effect: EffectScope)
for _, cleanup in effect.cleanups do
local prev = setCurrentSub(nil)
local success, result: unknown = (if strict.enabled then noYield else pcall)(cleanup)
local success, result: unknown = (if config.strict then noYield else pcall)(cleanup)
setCurrentSub(prev)
if not success then
error(result)
@ -136,7 +108,7 @@ local function run(effect: Effect | EffectScope, flags: ReactiveFlags): ()
local prev = setCurrentSub(effect)
local prevTracking = setCurrentTracking(true)
startTracking(effect)
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)((effect :: Effect).fn)
local success, result: (() -> ())? = (if config.strict then noYield else pcall)((effect :: Effect).fn)
setCurrentSub(prev)
endTracking(effect)
setCurrentTracking(prevTracking)
@ -194,7 +166,7 @@ local function updateComputed(computed: Computed): boolean
local prevTracking = setCurrentTracking(true)
startTracking(computed)
local oldValue = computed.value
local success, result = (if strict.enabled then noYield else pcall)(computed.getter, oldValue)
local success, result = (if config.strict then noYield else pcall)(computed.getter, oldValue)
setCurrentSub(prevSub)
endTracking(computed)
setCurrentTracking(prevTracking)
@ -262,7 +234,7 @@ local function atomGetterSetter<T>(atom: Atom<T>): (...((T) -> T) | T) -> T
local newValue = ...
if type(newValue) == "function" then
local prev = setCurrentSub(nil)
local success, result = (if strict.enabled then noYield else pcall)(newValue, oldValue)
local success, result = (if config.strict then noYield else pcall)(newValue, oldValue)
setCurrentSub(prev)
if not success then
error(result)
@ -380,7 +352,7 @@ local function effect(fn: () -> ...(() -> ())?): () -> ()
end
local prev = setCurrentSub(self)
local prevTracking = setCurrentTracking(true)
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn)
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn)
setCurrentSub(prev)
setCurrentTracking(prevTracking)
if not success then
@ -401,7 +373,7 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () ->
end
local prevSub = setCurrentSub(nil)
local prevScope = setCurrentScope(self)
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn)
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn)
setCurrentScope(prevScope)
setCurrentSub(prevSub)
if not success then

View file

@ -4,5 +4,5 @@ end
return {
-- Enable strict mode in O2
enabled = inlineTest() == "inlineTest",
strict = inlineTest() == "inlineTest",
}

View file

@ -1 +1,68 @@
export {};
type Key = string | number | symbol;
type Cleanup = () => void;
export interface Atom<T> {
(): T;
(value: T | ((value: T) => T)): T;
}
export type Selector<T> = () => T;
export type Equals<T> = (current: T, incoming: T) => boolean;
export const flags: {
strict: boolean;
};
export function atom<T>(value: T, equals?: Equals<T>): Atom<T>;
// Overload for undefined initial value
export function atom<T>(): Atom<T | undefined>;
export function computed<T>(getter: (previousValue?: T) => T, equals?: Equals<T>): () => T;
export function effect(callback: () => Cleanup | void): Cleanup;
export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup;
export function listen<T>(
callback: () => T,
listener: (value: T, previousValue: T | undefined, dispose: Cleanup) => void,
): Cleanup;
export function subscribe<T>(callback: () => T, listener: (value: T, previousValue: T) => void): Cleanup;
export function mapped<VI, KI, VO, KO = KI>(
callback: () => Map<KI, VI> | ReadonlyMap<KI, VI>,
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
): () => ReadonlyMap<KO, VO>;
// Overload for arrays
export function mapped<VI, VO, K extends Key = number>(
callback: () => readonly VI[],
mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
): () => K extends number ? readonly VO[] : { readonly [P in K]: VO };
// Overload for objects
export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
callback: () => { readonly [K in KI]: VI },
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
): () => { readonly [K in KO]: VO };
export function observe<V, K = number>(
callback: () => Map<K, V> | ReadonlyMap<K, V> | readonly V[],
observer: (value: V, key: K) => Cleanup | void,
): Cleanup;
// Overload for objects
export function observe<V, K extends Key>(
callback: () => { readonly [K in Key]: V },
observer: (value: V, key: K) => Cleanup | void,
): Cleanup;
export function onCleanup(callback: Cleanup, failSilently?: boolean): void;
export function batched<Args extends any[], Result>(
callback: (...args: Args) => Result,
...args: Args
): (...args: Args) => Result;
export function peek<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
export function untracked<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;

View file

@ -1,9 +1,10 @@
local batched = require("@self/batched")
local core = require("@self/core")
local flags = require("@self/flags")
local listen = require("@self/listen")
local mapped = require("@self/mapped")
local observe = require("@self/observe")
local strict = require("@self/strict")
local peek = require("@self/peek")
local subscribe = require("@self/subscribe")
local types = require("@self/types")
local untracked = require("@self/untracked")
@ -11,24 +12,19 @@ local untracked = require("@self/untracked")
export type Atom<T = any> = types.Atom<T>
export type Selector<T = any> = types.Selector<T>
local atom = core.atom
local computed = core.computed
local effect = core.effect
local effectScope = core.effectScope
local onCleanup = core.onCleanup
return {
core = core,
strict = strict,
atom = atom,
flags = flags,
atom = core.atom,
computed = core.computed,
effect = core.effect,
effectScope = core.effectScope,
onCleanup = core.onCleanup,
batched = batched,
computed = computed,
effect = effect,
effectScope = effectScope,
listen = listen,
mapped = mapped,
observe = observe,
onCleanup = onCleanup,
peek = peek,
subscribe = subscribe,
untracked = untracked,
}

View file

@ -1,5 +1,5 @@
local core = require("./core")
local untracked = require("./untracked")
local peek = require("./peek")
local computed = core.computed
local effect = core.effect
@ -19,7 +19,7 @@ local function listen<T>(selector: () -> T, listener: (state: T, previous: T?, d
dispose = effect(function()
local previous = current
current = tracked()
untracked(listener, current, previous, dispose)
peek(listener, current, previous, dispose)
end)
if disposed then

View file

@ -0,0 +1,18 @@
local core = require("./core")
local setCurrentTracking = core.setCurrentTracking
local function handlePeek(prevTracking: boolean, ok: boolean, ...)
setCurrentTracking(prevTracking)
if ok then
return ...
else
error((...))
end
end
local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
return handlePeek(setCurrentTracking(false), pcall(callback, ...))
end
return peek

View file

@ -1,5 +1,5 @@
local core = require("./core")
local untracked = require("./untracked")
local peek = require("./peek")
local computed = core.computed
local effect = core.effect
@ -18,7 +18,7 @@ local function subscribe<T>(selector: () -> T, listener: (state: T, lastState: T
if mounted then
mounted = false
else
untracked(listener, current, previous)
peek(listener, current, previous)
end
end)
end

View file

@ -1,9 +1,9 @@
local core = require("./core")
local setCurrentTracking = core.setCurrentTracking
local setCurrentSub = core.setCurrentSub
local function handleUntracked(prevTracking: boolean, ok: boolean, ...)
setCurrentTracking(prevTracking)
local function handleUntracked(prevSub: core.ReactiveNode?, ok: boolean, ...)
setCurrentSub(prevSub)
if ok then
return ...
else
@ -12,7 +12,7 @@ local function handleUntracked(prevTracking: boolean, ok: boolean, ...)
end
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
return handleUntracked(setCurrentTracking(false), pcall(callback, ...))
return handleUntracked(setCurrentSub(nil), pcall(callback, ...))
end
return untracked

View file

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

View file

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

View file

@ -1,445 +0,0 @@
--[[
alien-signals 2.0.5 implementation adopted from
https://github.com/Nicell/alien-signals-luau
https://github.com/stackblitz/alien-signals
MIT License
Copyright (c) 2024-present Johnson Chu, Nicholas Winans
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]
local strict = require("./strict")
local system = require("./system")
local utils = require("./utils")
export type ReactiveFlags = system.ReactiveFlags
export type ReactiveNode = system.ReactiveNode
export type Link = system.Link
local ReactiveFlags = system.ReactiveFlags
local createReactiveSystem = system.createReactiveSystem
local noYield = utils.noYield
local NONE = 0b0000000
local MUTABLE = 0b0000001
local WATCHING = 0b0000010
local DIRTY = 0b0010000
local PENDING = 0b0100000
local QUEUED = 0b1000000
type EffectScope = ReactiveNode & {
cleanups: { () -> () },
}
type Effect = ReactiveNode & {
fn: () -> ...(() -> ())?,
cleanups: { () -> () },
}
type Computed<T = any> = ReactiveNode & {
value: T?,
getter: (previousValue: T?) -> T,
equals: ((T, T) -> boolean)?,
}
type Atom<T = any> = ReactiveNode & {
previousValue: T,
value: T,
equals: ((T, T) -> boolean)?,
}
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 queuedEffects: { Effect | EffectScope? } = {}
local queuedEffectsLength = 0
local notifyIndex = 0
local activeSubTracking = true
local activeSub: ReactiveNode?
local activeScope: Effect | EffectScope?
local function getCurrentSub(): ReactiveNode?
return activeSub
end
local function setCurrentSub(sub: ReactiveNode?)
local prevSub = activeSub
activeSub = sub
return prevSub
end
local function getCurrentScope(): Effect | EffectScope?
return activeScope
end
local function setCurrentScope(scope: Effect | EffectScope?)
local prevScope = activeScope
activeScope = scope
return prevScope
end
local function setCurrentTracking(tracking: boolean): boolean
local prevTracking = activeSubTracking
activeSubTracking = tracking
return prevTracking
end
local function runCleanups(effect: EffectScope)
for _, cleanup in effect.cleanups do
local prev = setCurrentSub(nil)
local success, result: unknown = (if strict.enabled then noYield else pcall)(cleanup)
setCurrentSub(prev)
if not success then
error(result)
end
end
table.clear(effect.cleanups)
end
local function onCleanup(cleanup: () -> (), failSilently: boolean?)
if activeSub then
table.insert((activeSub :: Effect).cleanups, cleanup)
elseif activeScope then
table.insert(activeScope.cleanups, cleanup)
elseif not failSilently then
warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2))
end
end
local function run(effect: Effect | EffectScope, flags: ReactiveFlags): ()
if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then
runCleanups(effect)
local prev = setCurrentSub(effect)
local prevTracking = setCurrentTracking(true)
startTracking(effect)
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)((effect :: Effect).fn)
setCurrentSub(prev)
endTracking(effect)
setCurrentTracking(prevTracking)
if not success then
error(result)
elseif result then
table.insert(effect.cleanups, result)
end
return
elseif bit32.btest(flags, PENDING) then
(effect :: Effect).flags = bit32.band(flags, bit32.bnot(PENDING))
end
local link = effect.deps
while link do
local dep = link.dep
local depFlags = dep.flags
if bit32.btest(depFlags, QUEUED) then
depFlags = bit32.band(depFlags, bit32.bnot(QUEUED))
dep.flags = depFlags
run(dep :: Effect, depFlags)
end
link = link.nextDep
end
end
local function flush(): ()
while notifyIndex < queuedEffectsLength do
notifyIndex += 1
local effect = queuedEffects[notifyIndex] :: Effect
queuedEffects[notifyIndex] = nil
effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED))
run(effect, effect.flags)
end
notifyIndex = 0
queuedEffectsLength = 0
end
local function getBatchDepth(): number
return batchDepth
end
local function startBatch()
batchDepth += 1
end
local function endBatch()
batchDepth -= 1
if batchDepth == 0 then
flush()
end
end
local function updateComputed(computed: Computed): boolean
local prevSub = setCurrentSub(computed)
local prevTracking = setCurrentTracking(true)
startTracking(computed)
local oldValue = computed.value
local success, result = (if strict.enabled then noYield else pcall)(computed.getter, oldValue)
setCurrentSub(prevSub)
endTracking(computed)
setCurrentTracking(prevTracking)
if not success then
error(result)
end
local equals = computed.equals
if if equals then not equals(oldValue, result) else oldValue ~= result then
computed.value = result
return true
else
return false
end
end
local function updateAtom(atom: Atom, value: any): boolean
atom.flags = MUTABLE
if atom.previousValue ~= value then
atom.previousValue = value
return true
else
return false
end
end
local function readAtom<T>(atom: Atom<T>): T
local value = atom.value
if bit32.btest(atom.flags, DIRTY) then
if updateAtom(atom, value) then
local subs = atom.subs
if subs then
shallowPropagate(subs)
end
end
end
if activeSub and activeSubTracking then
link(atom, activeSub)
end
return value
end
local function writeAtom<T>(atom: Atom<T>, oldValue: T, newValue: T): T
local equals = atom.equals
if if equals then not equals(oldValue, newValue :: T) else oldValue ~= newValue then
atom.value = newValue
atom.flags = bit32.bor(MUTABLE, DIRTY)
local subs = atom.subs
if subs then
propagate(subs)
if batchDepth == 0 then
flush()
end
end
return newValue
end
return oldValue
end
local function atomGetterSetter<T>(atom: Atom<T>): (...((T) -> T) | T) -> T
return function(...)
if select("#", ...) == 0 then
return readAtom(atom)
end
local oldValue = atom.value
local newValue = ...
if type(newValue) == "function" then
local prev = setCurrentSub(nil)
local success, result = (if strict.enabled then noYield else pcall)(newValue, oldValue)
setCurrentSub(prev)
if not success then
error(result)
end
newValue = result
end
return writeAtom(atom, oldValue, newValue :: T)
end
end
local function computedGetter<T>(computed: Computed<T>): () -> T
return function()
local flags = computed.flags
if
bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(computed.deps :: Link, computed))
then
if updateComputed(computed) then
local subs = computed.subs
if subs then
shallowPropagate(subs)
end
end
elseif bit32.btest(flags, PENDING) then
computed.flags = bit32.band(flags, bit32.bnot(PENDING))
end
if activeSub then
if activeSubTracking then
link(computed, activeSub)
end
elseif activeScope then
link(computed, activeScope)
end
return computed.value :: T
end
end
local function effectStop(self: Effect | EffectScope): () -> ()
return function()
local dep = self.deps
while dep do
dep = unlink(dep, self)
end
local sub = self.subs
if sub then
unlink(sub)
end
(self :: Effect).flags = NONE
runCleanups(self)
end
end
local function update(atom: Atom | Computed): boolean
if (atom :: Computed).getter then
return updateComputed(atom :: Computed)
else
return updateAtom(atom :: Atom, atom.value)
end
end
local function notify(effect: Effect | EffectScope)
local flags = effect.flags
if not bit32.btest(flags, QUEUED) then
(effect :: Effect).flags = bit32.bor(flags, QUEUED)
local subs = effect.subs
if subs then
notify(subs.sub :: Effect)
else
queuedEffectsLength += 1
queuedEffects[queuedEffectsLength] = effect
end
end
end
local function unwatched(node: Atom | Computed | Effect | EffectScope)
if (node :: Computed).getter then
local toRemove = node.deps
if toRemove then
(node :: Computed).flags = bit32.bor(MUTABLE, DIRTY)
repeat
toRemove = unlink(toRemove, node)
until not toRemove
end
elseif (node :: Effect).cleanups then
effectStop(node :: Effect)()
end
end
local function atom<T>(initialValue: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T
return atomGetterSetter({
previousValue = initialValue,
value = initialValue,
flags = MUTABLE,
equals = equals,
})
end
local function computed<T>(getter: (previousValue: T?) -> T, equals: ((T, T) -> boolean)?): () -> T
return computedGetter({
flags = bit32.bor(MUTABLE, DIRTY),
getter = getter,
equals = equals,
})
end
local function effect(fn: () -> ...(() -> ())?): () -> ()
local self: Effect = {
fn = fn,
flags = WATCHING,
cleanups = {},
}
if activeSub then
link(self, activeSub)
elseif activeScope then
link(self, activeScope)
end
local prev = setCurrentSub(self)
local prevTracking = setCurrentTracking(true)
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn)
setCurrentSub(prev)
setCurrentTracking(prevTracking)
if not success then
error(result)
elseif result then
table.insert(self.cleanups, result)
end
return effectStop(self)
end
local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> ()
local self: EffectScope = {
flags = NONE,
cleanups = {},
}
if activeScope and not detached then
link(self, activeScope)
end
local prevSub = setCurrentSub(nil)
local prevScope = setCurrentScope(self)
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn)
setCurrentScope(prevScope)
setCurrentSub(prevSub)
if not success then
error(result)
elseif result then
table.insert(self.cleanups, result)
end
return effectStop(self)
end
local reactiveSystem = createReactiveSystem({
update = update :: (ReactiveNode) -> boolean,
notify = notify :: (ReactiveNode) -> (),
unwatched = unwatched :: (ReactiveNode) -> (),
})
link = reactiveSystem.link
unlink = reactiveSystem.unlink
propagate = reactiveSystem.propagate
checkDirty = reactiveSystem.checkDirty
endTracking = reactiveSystem.endTracking
startTracking = reactiveSystem.startTracking
shallowPropagate = reactiveSystem.shallowPropagate
return {
ReactiveFlags = ReactiveFlags,
createReactiveSystem = createReactiveSystem,
getCurrentSub = getCurrentSub,
setCurrentSub = setCurrentSub,
getCurrentScope = getCurrentScope,
setCurrentScope = setCurrentScope,
setCurrentTracking = setCurrentTracking,
onCleanup = onCleanup,
getBatchDepth = getBatchDepth,
startBatch = startBatch,
endBatch = endBatch,
atom = atom,
computed = computed,
effect = effect,
effectScope = effectScope,
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +0,0 @@
local function inlineTest(): string
return debug.info(1, "n")
end
return {
-- Enable strict mode in O2
enabled = inlineTest() == "inlineTest",
}

View file

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

View file

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

View file

@ -1,18 +0,0 @@
local core = require("./core")
local setCurrentTracking = core.setCurrentTracking
local function handleUntracked(prevTracking: boolean, ok: boolean, ...)
setCurrentTracking(prevTracking)
if ok then
return ...
else
error((...))
end
end
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
return handleUntracked(setCurrentTracking(false), pcall(callback, ...))
end
return untracked

View file

@ -1,18 +0,0 @@
local function handleNoYield(thread: thread, ok: boolean, ...)
if not ok then
return false, debug.traceback(thread, tostring((...)))
elseif coroutine.status(thread) ~= "dead" then
return false, debug.traceback(thread, "Attempted to yield!")
else
return true, ...
end
end
local function noYield<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...)
local thread = coroutine.create(callback)
return handleNoYield(thread, coroutine.resume(thread, ...))
end
return {
noYield = noYield :: typeof(pcall),
}

View file

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

View file

@ -13,11 +13,11 @@ local function renderHook<Props, Result>(callback: (Props) -> Result, props: Pro
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
local hook = {}
local function Component(props)
local function Component(props: Props)
hook.result = callback(props)
end
function hook.rerender(newProps)
function hook.rerender(newProps: Props?)
props = newProps or props
ReactRoblox.act(function()
root:render(React.createElement(Component, props))

View file

@ -1,15 +1,11 @@
local suite = require("../../../../test/suite")
suite.describe("react-charm", function()
if not game then
return suite.skip()
end
suite.describe("useAtom", function()
require("./useAtom.spec")
end)
suite.describe("useAtomBinding", function()
require("./useAtomBinding.spec")
end)
end)
return {}

View file

@ -1 +1,29 @@
export {};
import { Binding } from "@rbxts/react";
/**
* A hook that subscribes to changes in the given atom or selector. The
* component is re-rendered with the new state after an update.
*
* If the `dependencies` array is provided, a new effect is created for the
* current `callback` when the dependencies change. Otherwise, the effect is
* created only once, when the component is mounted.
*
* @param callback The atom or selector to watch.
* @param dependencies An array of values used to memoize the callback.
* @returns The current state.
*/
export function useAtom<T>(callback: () => T, dependencies?: any[]): T;
/**
* A hook that subscribes to changes in the given atom or selector. Returns
* a binding that updates when the state changes.
*
* If the `dependencies` array is provided, a new effect is created for the
* current `callback` when the dependencies change. Otherwise, the effect is
* created only once, when the component is mounted.
*
* @param callback The atom or selector to watch.
* @param dependencies An array of values used to memoize the callback.
* @returns A binding that stores the current state.
*/
export function useAtomBinding<T>(callback: () => T, dependencies?: any[]): Binding<T>;

View file

@ -8,21 +8,21 @@ local useState = React.useState
local useMemo = React.useMemo
--- A hook that subscribes to changes in the given atom or selector. The
--- component is re-rendered whenever the state changes.
--- component is re-rendered with the new state after an update.
---
--- If the `dependencies` array is provided, the effect is re-created whenever
--- the dependencies change. Otherwise, the subscription is created once when
--- the component is mounted.
--- If the `dependencies` array is provided, a new effect is created for the
--- current `callback` when the dependencies change. Otherwise, the effect is
--- created only once, when the component is mounted.
---
--- @param selector The atom or selector to subscribe to.
--- @param dependencies An array of values that the subscription depends on.
--- @param callback The atom or selector to watch.
--- @param dependencies An array of values used to memoize the callback.
--- @return The current state.
local function useAtom<State>(selector: () -> State, dependencies: { any }?): State
local state, setState = useState(selector)
local function useAtom<T>(callback: () -> T, dependencies: { any }?): T
local state, setState = useState(callback)
useEffect(function()
return effect(function()
setState(selector())
setState(callback())
end)
end, dependencies or {})
@ -32,20 +32,20 @@ end
--- A hook that subscribes to changes in the given atom or selector. Returns
--- a binding that updates when the state changes.
---
--- If the `dependencies` array is provided, the effect is re-created whenever
--- the dependencies change. Otherwise, the subscription is created once when
--- the component is mounted.
--- If the `dependencies` array is provided, a new effect is created for the
--- current `callback` when the dependencies change. Otherwise, the effect is
--- created only once, when the component is mounted.
---
--- @param selector The atom or selector to subscribe to.
--- @param dependencies An array of values that the subscription depends on.
--- @param callback The atom or selector to watch.
--- @param dependencies An array of values used to memoize the callback.
--- @return A binding that stores the current state.
local function useAtomBinding<State>(selector: () -> State, dependencies: { any }?)
local initialState = useMemo(selector, {})
local function useAtomBinding<T>(callback: () -> T, dependencies: { any }?)
local initialState = useMemo(callback, {})
local binding, setBinding = useBinding(initialState)
useEffect(function()
return effect(function()
setBinding(selector())
setBinding(callback())
end)
end, dependencies or {})

View file

@ -1,12 +1,7 @@
local suite = require("../../../../test/suite")
suite.describe("vide-charm", function()
if not game then
return suite.skip()
end
suite.describe("useAtom", function()
require("./useAtom.spec")
end)
end)
return {}

View file

@ -1 +1,8 @@
export {};
/**
* Subscribes to the state of an atom or selector. Returns a source that
* updates when the state changes.
*
* @param callback The atom or selector to subscribe to.
* @returns The reactive source.
*/
export function useAtom<T>(callback: () => T, dependencies?: any[]): () => T;

View file

@ -8,11 +8,11 @@ local cleanup = Vide.cleanup
--- Subscribes to the state of an atom or selector. Returns a source that
--- updates when the state changes.
---
--- @param selector The atom or selector to subscribe to.
--- @param callback The atom or selector to subscribe to.
--- @return The reactive source.
local function useAtom<State>(selector: () -> State): () -> State
local state = source(selector())
cleanup(subscribe(selector, state))
local function useAtom<State>(callback: () -> State): () -> State
local state = source(callback())
cleanup(subscribe(callback, state))
return state
end

View file

@ -1,11 +1,22 @@
local Charm = require("../packages/charm/src")
local suite = require("./suite")
Charm.strict.enabled = true
Charm.flags.strict = true
suite.describe("charm", function()
require("../packages/charm/src/__tests__/spec")
end)
suite.describe.ingame("charm-sync", function()
require("../packages/charm-sync/src/__tests__/spec")
end)
suite.describe.ingame("react-charm", function()
require("../packages/react-charm/src/__tests__/spec")
end)
suite.describe.ingame("vide-charm", function()
require("../packages/vide-charm/src/__tests__/spec")
end)
suite.finish()

@ -1 +1 @@
Subproject commit 2692eb055839cd56d0075ca72482658a5bdaddb9
Subproject commit eb95781a86e505cb6bff83ca568a67c7295ebb38