refactor: rewrite untracked, update alien-signals

This commit is contained in:
littensy 2025-09-23 16:50:39 -07:00
parent a8051257e5
commit cb52ece4d3
15 changed files with 239 additions and 114 deletions

View file

@ -4,6 +4,7 @@ local bench = require("./bench")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local listen = Charm.listen
for _, w in { 10, 100 } do
for _, h in { 10, 100 } do
@ -25,3 +26,22 @@ for _, w in { 10, 100 } do
end)
end
end
for _, w in { 10, 100 } do
for _, h in { 10, 100 } do
bench.run(`propagate (listen) {w}*{h}`, function()
local src = atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
listen(last, function() end)
end
src(src() + 1)
end)
end
end

View file

@ -7,7 +7,7 @@
"publish:npm": "pnpm publish -r --access public",
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
},
"packageManager": "pnpm@10.12.4",
"packageManager": "pnpm@10.17.1",
"devDependencies": {
"@rbxts-js/react": "17.1.20-ts.1",
"@rbxts-js/react-roblox": "17.1.20-ts.1",

View file

@ -4,13 +4,15 @@ type Atom<T> = Charm.Atom<T>
--- A payload that can be sent from the server to the client to synchronize
--- state between the two.
export type SyncPayload = {
type: "init",
data: { [string | number]: any },
} | {
type: "patch",
data: { [string | number]: any },
}
export type SyncPayload =
| {
type: "init",
data: { [string | number]: any },
}
| {
type: "patch",
data: { [string | number]: any },
}
export type AtomMap = {
[string | number]: Atom<any>,

View file

@ -356,4 +356,25 @@ test("should run cleanups outside any evaluation context", function()
assert(calls == 0, "should not run after b update")
end)
test("should allow self cleanup", function()
local calls = 0
local cleanups = 0
local a = atom(0)
effect(function(stopEffect)
onCleanup(function()
cleanups += 1
end)
stopEffect()
a()
calls += 1
end)
assert(calls == 1, "should call effect once")
assert(cleanups == 1, "should call cleanup once")
a(1)
assert(calls == 1, "should not call effect after a update")
assert(cleanups == 1, "should not call cleanup after a update")
end)
return {}

View file

@ -4,6 +4,7 @@ local suite = require("../../../../test/suite")
local atom = Charm.atom
local listen = Charm.listen
local effect = Charm.effect
local onCleanup = Charm.onCleanup
local test = suite.test
test("should listen to selector", function()
@ -115,4 +116,24 @@ test("should skip redundant updates", function()
assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`)
end)
test("should allow self cleanup", function()
local calls = 0
local cleanups = 0
local a = atom(0)
listen(a, function(_, _, stopEffect)
onCleanup(function()
cleanups += 1
end)
stopEffect()
calls += 1
end)
assert(calls == 1, "should call effect once")
assert(cleanups == 1, "should call cleanup once")
a(1)
assert(calls == 1, "should not call effect after a update")
assert(cleanups == 1, "should not call cleanup after a update")
end)
return {}

View file

@ -61,4 +61,36 @@ test("should dispose scopes", function()
assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
end)
test("should queue addition", function()
local items = atom({ 1 })
local edits = {}
observe(items, function(item)
items({ 1, 2 })
items({ 1, 2, 3, 4 })
table.insert(edits, `+{item}`)
return function()
table.insert(edits, `-{item}`)
end
end)
assert(table.concat(edits) == "+1+2+3+4", `bad edits: {table.concat(edits)}`)
end)
test("should queue removal", function()
local items = atom({ 1, 2, 3, 4 })
local edits = {}
observe(items, function(item)
items({ 1, 2, 3 })
items({ 1 })
table.insert(edits, `+{item}`)
return function()
table.insert(edits, `-{item}`)
end
end)
assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`)
end)
return {}

View file

@ -105,4 +105,26 @@ test("should block tracking inside computed atoms", function()
assert(c() == 3, "c should be 3")
end)
test("should track outside untracked", function()
local a = atom(1)
local b = atom(2)
local c = atom(3)
local spyCalls = 0
effect(function()
a()
untracked(c)
b()
spyCalls += 1
end)
assert(spyCalls == 1, "should be called once")
a(10)
assert(spyCalls == 2, "should be called twice")
b(20)
assert(spyCalls == 3, "should be called three times")
c(30)
assert(spyCalls == 3, "should not run on c change")
end)
return {}

View file

@ -1,7 +1,10 @@
local core = require("./core")
local flags = require("./flags")
local utils = require("./utils")
local startBatch = core.startBatch
local endBatch = core.endBatch
local noYield = utils.noYield
local function handleBatched(ok: boolean, ...)
endBatch()
@ -14,7 +17,7 @@ end
local function batched<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
startBatch()
return handleBatched(pcall(callback, ...))
return handleBatched((if flags.strict then noYield else pcall)(callback, ...))
end
return batched

View file

@ -21,14 +21,16 @@ local WATCHING = 0b0000010
local DIRTY = 0b0010000
local PENDING = 0b0100000
local QUEUED = 0b1000000
local UNTRACKED = 0b10000000
type EffectScope = ReactiveNode & {
cleanups: { () -> () },
}
type Effect = ReactiveNode & {
fn: () -> ...(() -> ())?,
fn: (dispose: () -> ()) -> ...(() -> ())?,
cleanups: { () -> () },
cleanup: () -> (),
}
type Computed<T = any> = ReactiveNode & {
@ -59,7 +61,6 @@ local notifyIndex = 0
local activeScope: Effect | EffectScope?
local activeSub: ReactiveNode?
local activeSubWatching = true
local function getCurrentSub(): ReactiveNode?
return activeSub
@ -81,16 +82,10 @@ local function setCurrentScope(scope: Effect | EffectScope?)
return prevScope
end
local function setCurrentWatching(watching: boolean): boolean
local prevSubWatching = activeSubWatching
activeSubWatching = watching
return prevSubWatching
end
local function runCleanups(effect: EffectScope)
for _, cleanup in effect.cleanups do
local prev = setCurrentSub(nil)
local success, result: any = (config.strict and noYield or pcall)(cleanup)
local success, result: any = (if config.strict then noYield else pcall)(cleanup)
setCurrentSub(prev)
if not success then
error(result)
@ -113,12 +108,10 @@ local function run(effect: Effect, 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 = setCurrentWatching(true)
startTracking(effect)
local success, result: (() -> ())? = (config.strict and noYield or pcall)(effect.fn)
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(effect.fn, effect.cleanup)
setCurrentSub(prev)
endTracking(effect)
setCurrentWatching(prevTracking)
if not success then
error(result)
elseif result then
@ -170,13 +163,11 @@ end
local function updateComputed(computed: Computed): boolean
local prevSub = setCurrentSub(computed)
local prevWatching = setCurrentWatching(true)
startTracking(computed)
local oldValue = computed.value
local success, result = (config.strict and noYield or pcall)(computed.getter, oldValue)
local success, result = (if config.strict then noYield else pcall)(computed.getter, oldValue)
setCurrentSub(prevSub)
endTracking(computed)
setCurrentWatching(prevWatching)
if not success then
error(result)
end
@ -209,7 +200,7 @@ local function readAtom<T>(atom: Atom<T>): T
end
end
end
if activeSub and activeSubWatching then
if activeSub and not bit32.btest(activeSub.flags, UNTRACKED) then
link(atom, activeSub)
end
return value
@ -244,7 +235,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 = (config.strict and noYield or pcall)(newValue, oldValue)
local success, result = (if config.strict then noYield else pcall)(newValue, oldValue)
setCurrentSub(prev)
if not success then
error(result)
@ -270,7 +261,7 @@ local function computedGetter<T>(computed: Computed<T>): () -> T
elseif bit32.btest(flags, PENDING) then
computed.flags = bit32.band(flags, bit32.bnot(PENDING))
end
if activeSub and activeSubWatching then
if activeSub and not bit32.btest(activeSub.flags, UNTRACKED) then
link(computed, activeSub)
elseif activeScope then
link(computed, activeScope)
@ -350,28 +341,28 @@ local function computed<T>(getter: (previousValue: T?) -> T, equals: ((T, T) ->
})
end
local function effect(fn: () -> ...(() -> ())?): () -> ()
local function effect(fn: (dispose: () -> ()) -> ...(() -> ())?): () -> ()
local self: Effect = {
fn = fn,
flags = WATCHING,
cleanups = {},
cleanup = nil :: any,
}
self.cleanup = effectStop(self)
if activeSub then
link(self, activeSub)
elseif activeScope then
link(self, activeScope)
end
local prev = setCurrentSub(self)
local prevWatching = setCurrentWatching(true)
local success, result: (() -> ())? = (config.strict and noYield or pcall)(fn)
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn, self.cleanup)
setCurrentSub(prev)
setCurrentWatching(prevWatching)
if not success then
error(result)
elseif result then
table.insert(self.cleanups, result)
end
return effectStop(self)
return self.cleanup
end
local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> ()
@ -384,7 +375,7 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () ->
end
local prevSub = setCurrentSub(nil)
local prevScope = setCurrentScope(self)
local success, result: (() -> ())? = (config.strict and noYield or pcall)(fn)
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn)
setCurrentScope(prevScope)
setCurrentSub(prevSub)
if not success then
@ -416,7 +407,6 @@ return {
setCurrentSub = setCurrentSub,
getCurrentScope = getCurrentScope,
setCurrentScope = setCurrentScope,
setCurrentWatching = setCurrentWatching,
onCleanup = onCleanup,
getBatchDepth = getBatchDepth,
startBatch = startBatch,

View file

@ -1,3 +1,5 @@
--!nolint DeprecatedApi
local batched = require("@self/batched")
local core = require("@self/core")
local flags = require("@self/flags")

View file

@ -3,6 +3,8 @@ local core = require("./core")
local effect = core.effect
local effectScope = core.effectScope
local onCleanup = core.onCleanup
local startBatch = core.startBatch
local endBatch = core.endBatch
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> ()
local scopes: { [K]: () -> () } = {}
@ -18,7 +20,7 @@ local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key
end
for key, value in currentItems do
if previousItems[key] == nil then
if previousItems[key] == nil and scopes[key] == nil then
scopes[key] = effectScope(function()
return observer(value, key)
end, true)
@ -37,7 +39,9 @@ local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key
local previousItems = currentItems
currentItems = selector()
if currentItems ~= previousItems then
startBatch()
updateScopes(currentItems, previousItems)
endBatch()
end
end)

View file

@ -11,6 +11,7 @@ export type ReactiveNode = {
}
export type Link = {
version: number,
dep: ReactiveNode,
sub: ReactiveNode,
prevSub: Link?,
@ -26,13 +27,14 @@ type 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 NONE = 0b00000000
local MUTABLE = 0b00000001
local WATCHING = 0b00000010
local RECURSED_CHECK = 0b00000100
local RECURSED = 0b00001000
local DIRTY = 0b00010000
local PENDING = 0b00100000
local UNTRACKED = 0b10000000
local ReactiveFlags = {
None = NONE,
@ -42,6 +44,7 @@ local ReactiveFlags = {
Recursed = RECURSED,
Dirty = DIRTY,
Pending = PENDING,
Untracked = UNTRACKED,
}
local function createReactiveSystem(props: {
@ -52,20 +55,15 @@ local function createReactiveSystem(props: {
local update = props.update
local notify = props.notify
local unwatched = props.unwatched
local currentVersion = 0
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
local link = sub.depsTail
while link do
if link == checkLink then
return true
end
link = link.prevDep
end
return false
end
@ -75,20 +73,18 @@ local function createReactiveSystem(props: {
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
local nextDep = if prevDep then prevDep.nextDep else sub.deps
if nextDep and nextDep.dep == dep then
nextDep.version = currentVersion
sub.depsTail = nextDep
return
end
local prevSub = dep.subsTail
if prevSub and prevSub.sub == sub and (not recursedCheck or isValidLink(prevSub, sub)) then
if prevSub and prevSub.version == currentVersion and prevSub.sub == sub then
return
end
local newLink: Link = {
version = currentVersion,
dep = dep,
sub = sub,
prevDep = prevDep,
@ -154,35 +150,34 @@ local function createReactiveSystem(props: {
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 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, 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
if bit32.btest(flags, MUTABLE) then
local subSubs = sub.subs
if subSubs then
link = subSubs
local nextSub = link.nextSub
if nextSub then
stack = { value = next, prev = stack }
next = nextSub
end
continue
end
end
@ -214,6 +209,7 @@ local function createReactiveSystem(props: {
end
local function startTracking(sub: ReactiveNode): ()
currentVersion += 1
sub.depsTail = nil
sub.flags = bit32.bor(bit32.band(sub.flags, bit32.bnot(bit32.bor(RECURSED, DIRTY, PENDING))), RECURSED_CHECK)
end
@ -230,10 +226,10 @@ local function createReactiveSystem(props: {
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
local flags = sub.flags
if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then
sub.flags = bit32.bor(flags, DIRTY)
if bit32.btest(flags, WATCHING) then
notify(sub)
end
end
@ -244,16 +240,15 @@ local function createReactiveSystem(props: {
local function checkDirty(link: Link, sub: ReactiveNode): boolean
local stack: Stack<Link>?
local checkDepth = 0
local dirty = false
repeat
local dep = link.dep
local depFlags = dep.flags
local dirty = false
local flags = dep.flags
if bit32.btest(sub.flags, DIRTY) then
dirty = true
elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
elseif bit32.band(flags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
if update(dep) then
local subs = dep.subs :: Link
if subs.nextSub then
@ -261,7 +256,7 @@ local function createReactiveSystem(props: {
end
dirty = true
end
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
elseif bit32.band(flags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
if link.nextSub or link.prevSub then
stack = { value = link, prev = stack }
end
@ -271,9 +266,12 @@ local function createReactiveSystem(props: {
continue
end
if not dirty and link.nextDep then
link = link.nextDep
continue
if not dirty then
local nextDep = link.nextDep
if nextDep then
link = nextDep
continue
end
end
local continueTop = false
@ -296,16 +294,17 @@ local function createReactiveSystem(props: {
sub = link.sub
continue
end
dirty = false
else
sub.flags = bit32.band(sub.flags, bit32.bnot(PENDING))
end
sub = link.sub
if link.nextDep then
link = link.nextDep
local nextDep = link.nextDep
if nextDep then
link = nextDep
continueTop = true
break
end
dirty = false
end
if continueTop then

View file

@ -1,9 +1,12 @@
local core = require("./core")
local setCurrentWatching = core.setCurrentWatching
local UNTRACKED = core.ReactiveFlags.Untracked
local getCurrentSub = core.getCurrentSub
local function handleUntracked(prevWatching: boolean, ok: boolean, ...)
setCurrentWatching(prevWatching)
local function handleUntracked(sub: core.ReactiveNode?, ok: boolean, ...)
if sub then
sub.flags = bit32.band(sub.flags, bit32.bnot(UNTRACKED))
end
if ok then
return ...
else
@ -12,7 +15,13 @@ local function handleUntracked(prevWatching: boolean, ok: boolean, ...)
end
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
return handleUntracked(setCurrentWatching(false), pcall(callback, ...))
local sub = getCurrentSub()
if not sub or bit32.btest(sub.flags, UNTRACKED) then
return callback(...)
else
sub.flags = bit32.bor(sub.flags, UNTRACKED)
end
return handleUntracked(sub, pcall(callback, ...))
end
return untracked

View file

@ -6,8 +6,8 @@
[tools]
wally = "UpliftGames/wally@0.3.2"
rojo = "rojo-rbx/rojo@7.5.1"
stylua = "johnnymorganz/stylua@2.1.0"
selene = "Kampfkarren/selene@0.28.0"
luau-lsp = "johnnymorganz/luau-lsp@1.51.0"
lune = "lune-org/lune@0.9.4"
lute = "luau-lang/lute@0.1.0-nightly.20250701"
stylua = "johnnymorganz/stylua@2.2.0"
selene = "Kampfkarren/selene@0.29.0"
luau-lsp = "johnnymorganz/luau-lsp@1.53.5"
lune = "lune-org/lune@0.10.2"
lute = "luau-lang/lute@0.1.0-nightly.20250923"

View file

@ -1,5 +1,5 @@
std = "selene_defs"
exclude = ["**/node_modules/**"]
exclude = ["*/node_modules/*"]
[lints]
global_usage = "allow"