feat: add deep freeze flag, misc refactors

This commit is contained in:
littensy 2025-07-05 13:38:18 -07:00
parent 708bc55de7
commit 77c98ca94a
22 changed files with 185 additions and 337 deletions

View file

@ -1,4 +1,6 @@
local CharmSync = require("../packages/charm-sync/src") local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CharmSync = require(ReplicatedStorage.packages["charm-sync"].src)
local patch = CharmSync.patch local patch = CharmSync.patch

View file

@ -0,0 +1,40 @@
local core = require("../../core")
local suite = require("../../../../../test/suite")
local atom = core.atom
local effect = core.effect
local test = suite.test
test("should accept equality function", function()
local calls = {}
local src = atom(0, function(current, incoming)
return current == incoming or incoming % 2 == 1
end)
effect(function()
table.insert(calls, src())
end)
src(2)
src(3)
assert(src() == 2, "should skip odd value")
src(4)
src(5)
assert(src() == 4, "should skip odd value")
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
end)
test("should enforce immutability", function()
local src = atom({ nested = {} })
assert(not pcall(function()
src().nested.a = 1
end), "should not allow mutation of initial value")
src({ nested = { a = 1 } })
assert(not pcall(function()
src().nested.a = 2
end), "should not allow mutation of new value")
end)
return {}

View file

@ -1,8 +1,9 @@
local core = require("../../core") local core = require("../../core")
local suite = require("../../../../../test/suite") local suite = require("../../../../../test/suite")
local computed = core.computed
local atom = core.atom local atom = core.atom
local computed = core.computed
local effect = core.effect
local test = suite.test local test = suite.test
test("should correctly propagate changes through computed core", function() test("should correctly propagate changes through computed core", function()
@ -80,4 +81,27 @@ test("should not update if the atom value is reverted", function()
assert(times == 1, "computed ran when it should not") assert(times == 1, "computed ran when it should not")
end) end)
test("should accept equality function", function()
local calls = {}
local src = atom(0)
local c1 = computed(function()
return src()
end, function(current, incoming)
return current == incoming or incoming % 2 == 1
end)
effect(function()
table.insert(calls, c1())
end)
src(2)
src(3)
assert(c1() == 2, "should skip odd value")
src(4)
src(5)
assert(c1() == 4, "should skip odd value")
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
end)
return {} return {}

View file

@ -8,15 +8,11 @@ local effectScope = core.effectScope
local test = suite.test local test = suite.test
--- Creates a weak reference to a value. --- Creates a weak reference to a value.
--- @return Function to dereference the value. local function weakRef<T>(value: T): { value: T? }
local function weakRef<T>(value: T): () -> T? return setmetatable({ value = value }, { __mode = "v" }) :: any
local ref = setmetatable({ value = value }, { __mode = "v" })
return function()
return ref.value
end
end end
--- Triggers garbage collection by allocating excessive memory. --- Triggers garbage collection by creating a large number of tables.
local function gc() local function gc()
for _ = 1, 1000 do for _ = 1, 1000 do
local _ = table.create(100, 0) local _ = table.create(100, 0)
@ -24,14 +20,14 @@ local function gc()
end end
--- In alien-signals, 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 --- parent scope is disposed. However, calling a computed value outside of a
--- it to dependencies, but the dependencies hold strong references to the --- scope still links it to dependencies, which act as strong references.
--- computed value, so it won't GC until called in a scope and disposed. --- Computed values called in this way won't GC until unlinked from a scope.
--- ---
--- This function releases a computed value by linking it to a scope and then --- This function releases a computed value by linking it to a scope and then
--- immediately closing it, causing alien-signals to unlink the computed value --- immediately closing it, causing alien-signals to unlink the computed value
--- and allow it to be garbage collected. --- from its dependencies and allow it to be garbage collected.
local function release(getter: () -> ()) local function unlink(getter: () -> ())
effectScope(function() effectScope(function()
getter() getter()
end)() end)()
@ -39,18 +35,17 @@ end
test("should release computed cache", function() test("should release computed cache", function()
local srcRef = weakRef({}) local srcRef = weakRef({})
local src = atom(srcRef()) local src = atom(srcRef.value)
local c = computed(function() local c = computed(function()
return src() return src()
end) end)
c() -- cache src value c() -- cache src value
src(nil) -- release value src(nil) -- release value
c = release(c) -- release computed c = unlink(c) -- release computed
gc() gc()
assert(srcRef() == nil, "should release computed cache") assert(srcRef.value == nil, "should release computed cache")
end) end)
test("should not release effect for atom", function() test("should not release effect for atom", function()
@ -61,7 +56,6 @@ test("should not release effect for atom", function()
effectCalls += 1 effectCalls += 1
src() src()
end) end)
assert(effectCalls == 1, "should call effect once") assert(effectCalls == 1, "should call effect once")
gc() gc()

View file

@ -1,113 +0,0 @@
local core = require("../../core")
local suite = require("../../../../../test/suite")
local computed = core.computed
local effect = core.effect
local setCurrentSub = core.setCurrentSub
local atom = core.atom
local test = suite.test
type ReactionOptions<T = unknown> = {
fireImmediately: boolean?,
equals: ((a: T, b: T?) -> boolean)?,
onError: ((error: unknown) -> ())?,
scheduler: ((fn: () -> ()) -> ())?,
once: boolean?,
}
local function untracked<T>(callback: () -> T): T
local currentSub = setCurrentSub(nil)
local success, result = pcall(callback)
setCurrentSub(currentSub)
if not success then
error(result)
end
return result
end
local function reaction<T>(
dataFn: () -> T,
effectFn: (newValue: T, oldValue: T?) -> (),
options: ReactionOptions<T>?
): () -> ()
local options: ReactionOptions<T> = options or {}
local scheduler: (() -> ()) -> () = options.scheduler or function(fn)
return fn()
end
local equals: (T, T?) -> boolean = options.equals or function(a, b)
return a == b
end
local onError = options.onError
local once = options.once or false
local fireImmediately = options.fireImmediately or false
local prevValue: T?
local version = 0
local tracked = computed(function()
local success, result = pcall(dataFn)
if not success then
untracked(function()
if onError then
onError(result)
end
return nil
end)
return prevValue
end
return result
end)
local dispose
dispose = effect(function()
local current = tracked()
if not fireImmediately and version == 0 then
prevValue = current
end
version += 1
if equals(current :: T, prevValue) then
return
end
local oldValue = prevValue
prevValue = current
untracked(function()
scheduler(function()
local success = pcall(effectFn, current :: T, oldValue)
if not success and onError then
onError(error)
end
if once then
if (fireImmediately and version > 1) or (not fireImmediately and version > 0) then
dispose()
end
end
end)
return nil
end)
end)
return dispose
end
test("#48", function()
local source = atom(0)
local disposeInner: () -> ()
reaction(function()
return source()
end, function(val)
if val == 1 then
disposeInner = reaction(function()
return source()
end, function() end)
elseif val == 2 then
disposeInner()
end
end)
source(1)
source(2)
source(3)
end)
return {}

View file

@ -1,23 +0,0 @@
local core = require("../../core")
local suite = require("../../../../../test/suite")
local computed = core.computed
local setCurrentSub = core.setCurrentSub
local atom = core.atom
local test = suite.test
test("should pause tracking", function()
local src = atom(0)
local c = computed(function()
local currentSub = setCurrentSub(nil)
local value = src()
setCurrentSub(currentSub)
return value
end)
assert(c() == 0, "should not track src")
src(1)
assert(c() == 0, "should not track src")
end)
return {}

View file

@ -98,4 +98,21 @@ test("should track inner subs", function()
assert(table.concat(order) == "", `bad order: {table.concat(order)}`) assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
end) end)
test("should skip redundant updates", function()
local order = {}
local a = atom(0)
listen(function(): "even" | "odd"
return if a() % 2 == 0 then "even" else "odd"
end, function(value)
table.insert(order, value)
end)
a(1)
a(3)
a(4)
a(6)
assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`)
end)
return {} return {}

View file

@ -1,107 +0,0 @@
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

@ -1,6 +1,9 @@
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
suite.describe("core", function() suite.describe("core", function()
suite.describe("atom", function()
require("./core/atom.spec")
end)
suite.describe("computed", function() suite.describe("computed", function()
require("./core/computed.spec") require("./core/computed.spec")
end) end)
@ -10,18 +13,12 @@ suite.describe("core", function()
suite.describe("effectScope", function() suite.describe("effectScope", function()
require("./core/effectScope.spec") require("./core/effectScope.spec")
end) end)
suite.describe("issue_48", function()
require("./core/issue_48.spec")
end)
suite.describe("gc", function() suite.describe("gc", function()
require("./core/gc.spec") require("./core/gc.spec")
end) end)
suite.describe("topology", function() suite.describe("topology", function()
require("./core/topology.spec") require("./core/topology.spec")
end) end)
suite.describe("untrack", function()
require("./core/untrack.spec")
end)
end) end)
suite.describe("listen", function() suite.describe("listen", function()

View file

@ -103,8 +103,6 @@ test("should track inner subs", function()
end) end)
end) end)
assert(table.concat(order) == "", "should not run immediately")
a(a() + 1) a(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
@ -118,4 +116,21 @@ test("should track inner subs", function()
assert(table.concat(order) == "", `bad order: {table.concat(order)}`) assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
end) end)
test("should skip redundant updates", function()
local order = {}
local a = atom(0)
subscribe(function(): "even" | "odd"
return if a() % 2 == 0 then "even" else "odd"
end, function(value)
table.insert(order, value)
end)
a(1)
a(3)
a(4)
a(6)
assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`)
end)
return {} return {}

View file

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

View file

@ -1,3 +1,7 @@
-- Adapted from Alien Signals
-- https://github.com/stackblitz/alien-signals
-- https://github.com/Nicell/alien-signals-luau
local config = require("./flags") local config = require("./flags")
local system = require("./system") local system = require("./system")
local utils = require("./utils") local utils = require("./utils")
@ -8,6 +12,7 @@ export type Link = system.Link
local ReactiveFlags = system.ReactiveFlags local ReactiveFlags = system.ReactiveFlags
local createReactiveSystem = system.createReactiveSystem local createReactiveSystem = system.createReactiveSystem
local deepFreeze = utils.deepFreeze
local noYield = utils.noYield local noYield = utils.noYield
local NONE = 0b0000000 local NONE = 0b0000000
@ -211,6 +216,9 @@ end
local function writeAtom<T>(atom: Atom<T>, oldValue: T, newValue: T): T local function writeAtom<T>(atom: Atom<T>, oldValue: T, newValue: T): T
local equals = atom.equals local equals = atom.equals
if if equals then not equals(oldValue, newValue :: T) else oldValue ~= newValue then if if equals then not equals(oldValue, newValue :: T) else oldValue ~= newValue then
if config.frozen then
deepFreeze(newValue)
end
atom.value = newValue atom.value = newValue
atom.flags = bit32.bor(MUTABLE, DIRTY) atom.flags = bit32.bor(MUTABLE, DIRTY)
local subs = atom.subs local subs = atom.subs
@ -323,6 +331,9 @@ local function unwatched(node: Atom | Computed | Effect | EffectScope)
end end
local function atom<T>(initialValue: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T local function atom<T>(initialValue: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T
if config.frozen then
deepFreeze(initialValue)
end
return atomGetterSetter({ return atomGetterSetter({
previousValue = initialValue, previousValue = initialValue,
value = initialValue, value = initialValue,

View file

@ -2,7 +2,13 @@ local function inlineTest(): string
return debug.info(1, "n") return debug.info(1, "n")
end end
local isO2 = inlineTest() ~= "inlineTest"
return { return {
-- Enable strict mode in O2 --- Enforces synchronous, non-yielding behavior in atoms and effects.
strict = inlineTest() == "inlineTest", --- Enabled if the optimize level is below 2, which is the case in studio.
strict = not isO2,
--- Enforces immutability of atoms by deep-freezing table values. Disabled
--- by default, but can be enabled to detect accidental mutations.
frozen = false,
} }

View file

@ -12,6 +12,7 @@ export type Equals<T> = (current: T, incoming: T) => boolean;
export const flags: { export const flags: {
strict: boolean; strict: boolean;
frozen: boolean;
}; };
export function atom<T>(value: T, equals?: Equals<T>): Atom<T>; export function atom<T>(value: T, equals?: Equals<T>): Atom<T>;
@ -63,6 +64,14 @@ export function batched<Args extends any[], Result>(
...args: Args ...args: Args
): (...args: Args) => Result; ): (...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; export function untracked<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
/**
* @deprecated Use `batched()` instead.
*/
export const batch: typeof batched;
/**
* @deprecated Use `untracked()` instead.
*/
export const peek: typeof untracked;

View file

@ -4,7 +4,6 @@ local flags = require("@self/flags")
local listen = require("@self/listen") local listen = require("@self/listen")
local mapped = require("@self/mapped") local mapped = require("@self/mapped")
local observe = require("@self/observe") local observe = require("@self/observe")
local peek = require("@self/peek")
local subscribe = require("@self/subscribe") local subscribe = require("@self/subscribe")
local types = require("@self/types") local types = require("@self/types")
local untracked = require("@self/untracked") local untracked = require("@self/untracked")
@ -12,6 +11,11 @@ local untracked = require("@self/untracked")
export type Atom<T = any> = types.Atom<T> export type Atom<T = any> = types.Atom<T>
export type Selector<T = any> = types.Selector<T> export type Selector<T = any> = types.Selector<T>
--- @deprecated Function `batch()` is deprecated, use `batched()` instead.
local batch = batched
--- @deprecated Function `peek()` is deprecated, use `untracked()` instead.
local peek = untracked
return { return {
core = core, core = core,
flags = flags, flags = flags,
@ -24,7 +28,9 @@ return {
listen = listen, listen = listen,
mapped = mapped, mapped = mapped,
observe = observe, observe = observe,
peek = peek,
subscribe = subscribe, subscribe = subscribe,
untracked = untracked, untracked = untracked,
-- Temporary aliases for backwards compatibility
batch = batch,
peek = peek,
} }

View file

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

View file

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

View file

@ -1,30 +1,6 @@
--[[ -- Adapted from Alien Signals
alien-signals 2.0.1 implementation adopted from -- https://github.com/stackblitz/alien-signals
https://github.com/Nicell/alien-signals-luau -- 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 = { export type ReactiveNode = {
deps: Link?, deps: Link?,

View file

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

View file

@ -13,6 +13,16 @@ local function noYield<Args..., Results...>(callback: (Args...) -> Results..., .
return handleNoYield(thread, coroutine.resume(thread, ...)) return handleNoYield(thread, coroutine.resume(thread, ...))
end end
local function deepFreeze(object: any)
if type(object) == "table" and not table.isfrozen(object) then
table.freeze(object)
for _, value in object do
deepFreeze(value)
end
end
end
return { return {
noYield = noYield :: typeof(pcall), noYield = noYield :: typeof(pcall),
deepFreeze = deepFreeze,
} }

View file

@ -2,6 +2,7 @@ local Charm = require("../packages/charm/src")
local suite = require("./suite") local suite = require("./suite")
Charm.flags.strict = true Charm.flags.strict = true
Charm.flags.frozen = true
suite.describe("charm", function() suite.describe("charm", function()
require("../packages/charm/src/__tests__/spec") require("../packages/charm/src/__tests__/spec")