refactor: align with alien-signals 3.0

This commit is contained in:
littensy 2025-10-18 21:59:34 -07:00
parent 7a63993293
commit bf92413a97
5 changed files with 254 additions and 279 deletions

View file

@ -5,7 +5,7 @@ local computed = core.computed
local effect = core.effect local effect = core.effect
local effectScope = core.effectScope local effectScope = core.effectScope
local endBatch = core.endBatch local endBatch = core.endBatch
local setCurrentSub = core.setCurrentSub local setActiveSub = core.setActiveSub
local onCleanup = core.onCleanup local onCleanup = core.onCleanup
local atom = core.atom local atom = core.atom
local startBatch = core.startBatch local startBatch = core.startBatch
@ -202,9 +202,9 @@ test("should duplicate subscribers do not affect the notify order", function()
effect(function() effect(function()
table.insert(order, "a") table.insert(order, "a")
local currentSub = setCurrentSub(nil) local currentSub = setActiveSub(nil)
local isOne = src2() == 1 local isOne = src2() == 1
setCurrentSub(currentSub) setActiveSub(currentSub)
if isOne then if isOne then
src1() src1()
end end

View file

@ -1,7 +1,3 @@
-- Adapted from Alien Signals
-- https://github.com/stackblitz/alien-signals
-- https://github.com/Nicell/alien-signals-luau
export type ReactiveNode = { export type ReactiveNode = {
deps: Link?, deps: Link?,
depsTail: Link?, depsTail: Link?,
@ -34,7 +30,7 @@ local RECURSED_CHECK = 0b00000100
local RECURSED = 0b00001000 local RECURSED = 0b00001000
local DIRTY = 0b00010000 local DIRTY = 0b00010000
local PENDING = 0b00100000 local PENDING = 0b00100000
local UNTRACKED = 0b10000000 local PEEKING = 0b10000000
local ReactiveFlags = { local ReactiveFlags = {
None = NONE, None = NONE,
@ -44,7 +40,6 @@ local ReactiveFlags = {
Recursed = RECURSED, Recursed = RECURSED,
Dirty = DIRTY, Dirty = DIRTY,
Pending = PENDING, Pending = PENDING,
Untracked = UNTRACKED,
} }
local function createReactiveSystem(props: { local function createReactiveSystem(props: {
@ -55,7 +50,6 @@ local function createReactiveSystem(props: {
local update = props.update local update = props.update
local notify = props.notify local notify = props.notify
local unwatched = props.unwatched local unwatched = props.unwatched
local currentVersion = 0
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
local link = sub.depsTail local link = sub.depsTail
@ -68,23 +62,27 @@ local function createReactiveSystem(props: {
return false return false
end end
local function link(dep: ReactiveNode, sub: ReactiveNode): () local function link(dep: ReactiveNode, sub: ReactiveNode, version: number): ()
-- DEVIATION: Do not link signals to sub while peeking
if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then
return
end
local prevDep = sub.depsTail local prevDep = sub.depsTail
if prevDep and prevDep.dep == dep then if prevDep and prevDep.dep == dep then
return return
end end
local nextDep = if prevDep then prevDep.nextDep else sub.deps local nextDep = if prevDep then prevDep.nextDep else sub.deps
if nextDep and nextDep.dep == dep then if nextDep and nextDep.dep == dep then
nextDep.version = currentVersion nextDep.version = version
sub.depsTail = nextDep sub.depsTail = nextDep
return return
end end
local prevSub = dep.subsTail local prevSub = dep.subsTail
if prevSub and prevSub.version == currentVersion and prevSub.sub == sub then if prevSub and prevSub.version == version and prevSub.sub == sub then
return return
end end
local newLink: Link = { local newLink: Link = {
version = currentVersion, version = version,
dep = dep, dep = dep,
sub = sub, sub = sub,
prevDep = prevDep, prevDep = prevDep,
@ -208,21 +206,6 @@ local function createReactiveSystem(props: {
until false until false
end 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
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): () local function shallowPropagate(link: Link): ()
repeat repeat
local sub = link.sub local sub = link.sub
@ -320,8 +303,6 @@ local function createReactiveSystem(props: {
unlink = unlink, unlink = unlink,
propagate = propagate, propagate = propagate,
checkDirty = checkDirty, checkDirty = checkDirty,
endTracking = endTracking,
startTracking = startTracking,
shallowPropagate = shallowPropagate, shallowPropagate = shallowPropagate,
} }
end end

View file

@ -1,7 +1,3 @@
-- Adapted from Alien Signals
-- https://github.com/stackblitz/alien-signals
-- https://github.com/Nicell/alien-signals-luau
local alien = require("./alien") local alien = require("./alien")
local config = require("./flags") local config = require("./flags")
local utils = require("./utils") local utils = require("./utils")
@ -15,22 +11,23 @@ local createReactiveSystem = alien.createReactiveSystem
local deepFreeze = utils.deepFreeze local deepFreeze = utils.deepFreeze
local noYield = utils.noYield local noYield = utils.noYield
local NONE = 0b0000000 local NONE = 0b00000000
local MUTABLE = 0b0000001 local MUTABLE = 0b00000001
local WATCHING = 0b0000010 local WATCHING = 0b00000010
local DIRTY = 0b0010000 local RECURSED_CHECK = 0b00000100
local PENDING = 0b0100000 local DIRTY = 0b00010000
local QUEUED = 0b1000000 local PENDING = 0b00100000
local UNTRACKED = 0b10000000 local QUEUED = 0b01000000
local PEEKING = 0b10000000
type EffectScope = ReactiveNode & { type EffectScope = ReactiveNode & {
cleanups: { () -> () }, cleanups: { () -> () },
} }
type Effect = ReactiveNode & { type Effect = ReactiveNode & {
fn: (dispose: () -> ()) -> ...(() -> ())?, fn: (stop: () -> ()) -> (() -> ())?,
cleanups: { () -> () }, cleanups: { () -> () },
cleanup: () -> (), stop: () -> (),
} }
type Computed<T = any> = ReactiveNode & { type Computed<T = any> = ReactiveNode & {
@ -38,54 +35,47 @@ type Computed<T = any> = ReactiveNode & {
getter: (previousValue: T?) -> T, getter: (previousValue: T?) -> T,
} }
type Atom<T = any> = ReactiveNode & { type Signal<T = any> = ReactiveNode & {
previousValue: T, currentValue: T,
value: T, pendingValue: T,
equals: ((T, T) -> boolean)?,
} }
local link: (dep: ReactiveNode, sub: ReactiveNode) -> () local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> ()
local unlink: (link: Link, sub: ReactiveNode?) -> Link? local unlink: (link: Link, sub: ReactiveNode?) -> Link?
local propagate: (link: Link) -> () local propagate: (link: Link) -> ()
local checkDirty: (link: Link, sub: ReactiveNode) -> boolean local checkDirty: (link: Link, sub: ReactiveNode) -> boolean
local endTracking: (sub: ReactiveNode) -> ()
local startTracking: (sub: ReactiveNode) -> ()
local shallowPropagate: (link: Link) -> () local shallowPropagate: (link: Link) -> ()
local cycle = 0
local batchDepth = 0 local batchDepth = 0
local queuedEffects: { Effect } = {}
local queuedEffectsLength = 0
local notifyIndex = 0 local notifyIndex = 0
local queuedEffects: { Effect | EffectScope? } = {}
local activeScope: Effect | EffectScope? local queuedEffectsLength = 0
local activeSub: ReactiveNode? local activeSub: ReactiveNode?
local function getCurrentSub(): ReactiveNode? local function getActiveSub(): ReactiveNode?
return activeSub return activeSub
end end
local function setCurrentSub(sub: ReactiveNode?) local function setActiveSub(sub: ReactiveNode?)
local prevSub = activeSub local prevSub = activeSub
activeSub = sub activeSub = sub
return prevSub return prevSub
end end
local function getCurrentScope(): Effect | EffectScope? local function purgeDeps(sub: ReactiveNode)
return activeScope local depsTail = sub.depsTail
end local dep = if depsTail then depsTail.nextDep else sub.deps
while dep do
local function setCurrentScope(scope: Effect | EffectScope?) dep = unlink(dep, sub)
local prevScope = activeScope end
activeScope = scope
return prevScope
end end
local function runCleanups(effect: EffectScope) local function runCleanups(effect: EffectScope)
for _, cleanup in effect.cleanups do for _, cleanup in effect.cleanups do
local prev = setCurrentSub(nil) local prevSub = setActiveSub(nil)
local success, result: any = (if config.strict then noYield else pcall)(cleanup) local success, result: any = (if config.strict then noYield else pcall)(cleanup)
setCurrentSub(prev) activeSub = prevSub
if not success then if not success then
error(result) error(result)
end end
@ -93,40 +83,49 @@ local function runCleanups(effect: EffectScope)
table.clear(effect.cleanups) table.clear(effect.cleanups)
end end
local function run(effect: Effect, flags: ReactiveFlags): () local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags)
if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then sub.flags = bit32.band(flags, bit32.bnot(PENDING))
runCleanups(effect) end
local prev = setCurrentSub(effect)
startTracking(effect) local function run(e: Effect | EffectScope, flags: ReactiveFlags): ()
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(effect.fn, effect.cleanup) if
setCurrentSub(prev) bit32.btest(flags, DIRTY)
endTracking(effect) or (bit32.btest(flags, PENDING) and (checkDirty(e.deps :: Link, e) or setNotPending(e, flags)))
then
runCleanups(e)
local e = e :: Effect
cycle += 1
e.depsTail = nil
e.flags = bit32.bor(WATCHING, RECURSED_CHECK)
local prevSub = setActiveSub(e)
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(e.fn, e.stop)
activeSub = prevSub
e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK))
purgeDeps(e)
if not success then if not success then
error(result) error(result)
elseif result then elseif result then
table.insert(effect.cleanups, result) table.insert(e.cleanups, result)
end end
return else
elseif bit32.btest(flags, PENDING) then local link = e.deps
effect.flags = bit32.band(flags, bit32.bnot(PENDING)) while link do
end local dep = link.dep
local link = effect.deps local depFlags = dep.flags
while link do if bit32.btest(depFlags, QUEUED) then
local dep = link.dep depFlags = bit32.band(depFlags, bit32.bnot(QUEUED))
local depFlags = dep.flags dep.flags = depFlags
if bit32.btest(depFlags, QUEUED) then run(dep :: Effect, depFlags)
depFlags = bit32.band(depFlags, bit32.bnot(QUEUED)) end
dep.flags = depFlags link = link.nextDep
run(dep :: Effect, depFlags)
end end
link = link.nextDep
end end
end end
local function flush(): () local function flush(): ()
while notifyIndex < queuedEffectsLength do while notifyIndex < queuedEffectsLength do
notifyIndex += 1 notifyIndex += 1
local effect = queuedEffects[notifyIndex] local effect = queuedEffects[notifyIndex] :: Effect
queuedEffects[notifyIndex] = nil queuedEffects[notifyIndex] = nil
effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED)) effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED))
run(effect, effect.flags) run(effect, effect.flags)
@ -150,165 +149,136 @@ local function endBatch()
end end
end end
local function updateComputed(computed: Computed): boolean local function updateComputed(c: Computed): boolean
local prevSub = setCurrentSub(computed) cycle += 1
startTracking(computed) c.depsTail = nil
local oldValue = computed.value c.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
local success, result = (if config.strict then noYield else pcall)(computed.getter, oldValue) local prevSub = setActiveSub(c)
setCurrentSub(prevSub) local oldValue = c.value
endTracking(computed) local success, result: unknown = (if config.strict then noYield else pcall)(c.getter, oldValue)
activeSub = prevSub
c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK))
purgeDeps(c)
if not success then if not success then
error(result) error(result)
end end
if oldValue ~= result then if 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 not bit32.btest(activeSub.flags, UNTRACKED) 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) else oldValue ~= newValue then
if config.frozen then if config.frozen then
deepFreeze(newValue) deepFreeze(result)
end end
atom.value = newValue c.value = result
atom.flags = bit32.bor(MUTABLE, DIRTY) return true
local subs = atom.subs else
if subs then return false
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 config.strict 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
end end
local function computedGetter<T>(computed: Computed<T>): () -> T local function updateSignal(s: Signal): boolean
return function() s.flags = MUTABLE
local flags = computed.flags local pendingValue = s.pendingValue
if s.currentValue ~= pendingValue then
s.currentValue = pendingValue
return true
else
return false
end
end
local function computedOper<T>(this: Computed<T>): () -> T
return function(): T
local flags = this.flags
if if
bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(computed.deps :: Link, computed)) bit32.btest(flags, DIRTY)
or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or setNotPending(this, flags)))
then then
if updateComputed(computed) then if updateComputed(this) then
local subs = computed.subs local subs = this.subs
if subs then if subs then
shallowPropagate(subs) shallowPropagate(subs)
end end
end end
elseif bit32.btest(flags, PENDING) then
computed.flags = bit32.band(flags, bit32.bnot(PENDING))
end end
if activeSub and not bit32.btest(activeSub.flags, UNTRACKED) then if activeSub then
link(computed, activeSub) link(this, activeSub, cycle)
elseif activeScope then
link(computed, activeScope)
end end
return computed.value :: T return this.value :: T
end end
end end
local function effectStop(self: EffectScope): () -> () local function signalOper<T>(this: Signal<T>, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T
return function() return function(...: ((T) -> T) | T): T
local dep = self.deps if select("#", ...) ~= 0 then
while dep do local update = ...
dep = unlink(dep, self) local value: T
end if type(update) == "function" then
local sub = self.subs local prevSub = setActiveSub(nil)
if sub then local success, result: T = (if config.strict then noYield else pcall)(update, this.currentValue)
unlink(sub) activeSub = prevSub
end if not success then
self.flags = NONE error(result)
runCleanups(self) end
end value = result
end else
value = update
local function update(atom: ReactiveNode): boolean end
if (atom :: Computed).getter then if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then
return updateComputed(atom :: Computed) if config.frozen then
else deepFreeze(value)
return updateAtom(atom :: Atom, (atom :: Atom).value) end
end this.pendingValue = value
end this.flags = bit32.bor(MUTABLE, DIRTY)
local subs = this.subs
local function notify(effect: ReactiveNode) if subs then
local flags = effect.flags propagate(subs)
if not bit32.btest(flags, QUEUED) then if batchDepth == 0 then
effect.flags = bit32.bor(flags, QUEUED) flush()
local subs = effect.subs end
if subs then end
notify(subs.sub :: Effect) return value
end
return this.currentValue
else else
queuedEffectsLength += 1 if bit32.btest(this.flags, DIRTY) then
queuedEffects[queuedEffectsLength] = effect :: Effect if updateSignal(this) then
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
end
local sub = activeSub
while sub do
if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then
link(this, sub, cycle)
break
end
local subs = sub.subs
if subs then
sub = subs.sub
end
end
return this.currentValue
end end
end end
end end
local function unwatched(node: ReactiveNode) local function effectScopeOper(this: EffectScope): () -> ()
if (node :: Computed).getter then return function()
local toRemove = node.deps this.depsTail = nil
if toRemove then this.flags = NONE
node.flags = bit32.bor(MUTABLE, DIRTY) purgeDeps(this)
repeat local sub = this.subs
toRemove = unlink(toRemove, node) if sub then
until not toRemove unlink(sub, this)
end end
elseif (node :: Effect).cleanups then runCleanups(this)
effectStop(node :: Effect)()
end end
end end
local function effectOper(this: Effect): () -> ()
return effectScopeOper(this)
end
--- Creates a reactive atom that acts as both a getter and setter. Calling the --- Creates a reactive atom that acts as both a getter and setter. Calling the
--- atom with an argument sets its value, while calling it with no arguments --- atom with an argument sets its value, while calling it with no arguments
--- returns its current value. --- returns its current value.
@ -316,16 +286,15 @@ end
--- @param value The initial value of the atom. If empty, the atom's value will be `undefined`. --- @param value The initial value of the atom. If empty, the atom's value will be `undefined`.
--- @param equals A comparator function to determine if the atom's value has changed. --- @param equals A comparator function to determine if the atom's value has changed.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-options --- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-options
local function atom<T>(value: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T
if config.frozen then if config.frozen then
deepFreeze(value) deepFreeze(initialValue)
end end
return atomGetterSetter({ return signalOper({
previousValue = value, currentValue = initialValue,
value = value, pendingValue = initialValue,
flags = MUTABLE, flags = MUTABLE,
equals = equals, }, equals)
})
end end
--- Creates a new atom that is computed based on the values of other atoms. --- Creates a new atom that is computed based on the values of other atoms.
@ -345,11 +314,10 @@ end
--- @param getter A function that produces the next value. --- @param getter A function that produces the next value.
--- @param value An optional initial value for the selector. --- @param value An optional initial value for the selector.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options --- @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options
local function computed<T>(getter: (previousValue: T?) -> T, value: T?): () -> T local function computed<T>(getter: (previousValue: T?) -> T): () -> T
return computedGetter({ return computedOper({
flags = bit32.bor(MUTABLE, DIRTY), flags = bit32.bor(MUTABLE, DIRTY),
getter = getter, getter = getter,
value = value,
}) })
end end
@ -366,28 +334,32 @@ end
--- @param fn The function to run when dependencies change. --- @param fn The function to run when dependencies change.
--- @return A function for disposing the effect. --- @return A function for disposing the effect.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback --- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
local function effect(fn: (dispose: () -> ()) -> ...(() -> ())?): () -> () local function effect(fn: (stop: () -> ()) -> ()): () -> ()
local self: Effect = { local e: Effect = {
fn = fn, fn = fn,
flags = WATCHING, flags = WATCHING,
cleanups = {}, cleanups = {},
cleanup = nil :: any, stop = nil :: any,
} }
self.cleanup = effectStop(self) e.stop = effectOper(e)
if activeSub and config.trackInnerEffects then local prevSub = setActiveSub(e)
link(self, activeSub) if prevSub and (config.trackInnerEffects or prevSub.flags == NONE) then
elseif activeScope then link(e, prevSub, 0)
link(self, activeScope)
end end
local prev = setCurrentSub(self) local stopped = false
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn, self.cleanup) local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn, function()
setCurrentSub(prev) stopped = true
end)
activeSub = prevSub
if not success then if not success then
error(result) error(result)
elseif result then elseif result then
table.insert(self.cleanups, result) table.insert(e.cleanups, result)
end end
return self.cleanup if stopped then
e.stop()
end
return e.stop
end end
--- Creates an effect scope that can capture multiple reactive effects (i.e. --- Creates an effect scope that can capture multiple reactive effects (i.e.
@ -398,25 +370,23 @@ end
--- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. --- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
--- @return A function for disposing all effects created within the scope. --- @return A function for disposing all effects created within the scope.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached --- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () local function effectScope(fn: () -> (), detached: boolean?): () -> ()
local self: EffectScope = { local e: EffectScope = {
flags = NONE, flags = NONE,
cleanups = {}, cleanups = {},
} }
if activeScope and not detached then local prevSub = setActiveSub(e)
link(self, activeScope) if prevSub and not detached then
link(e, prevSub, 0)
end end
local prevSub = setCurrentSub(nil)
local prevScope = setCurrentScope(self)
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn) local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn)
setCurrentScope(prevScope) activeSub = prevSub
setCurrentSub(prevSub)
if not success then if not success then
error(result) error(result)
elseif result then elseif result then
table.insert(self.cleanups, result) table.insert(e.cleanups, result)
end end
return effectStop(self) return effectScopeOper(e)
end end
--- Registers a cleanup function to the current active effect or effect scope. --- Registers a cleanup function to the current active effect or effect scope.
@ -429,8 +399,6 @@ end
local function onCleanup(cleanup: () -> (), failSilently: boolean?) local function onCleanup(cleanup: () -> (), failSilently: boolean?)
if activeSub and (activeSub :: Effect).cleanups then if activeSub and (activeSub :: Effect).cleanups then
table.insert((activeSub :: Effect).cleanups, cleanup) table.insert((activeSub :: Effect).cleanups, cleanup)
elseif activeScope then
table.insert(activeScope.cleanups, cleanup)
elseif not failSilently then elseif not failSilently then
warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2))
end end
@ -445,11 +413,9 @@ end
--- @param args Arguments to pass to the callback. --- @param args Arguments to pass to the callback.
--- @return The return value of the callback. --- @return The return value of the callback.
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results... local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
local prevSub = setCurrentSub(nil) local prevSub = setActiveSub(nil)
local prevScope = setCurrentScope(nil)
local results: { any } = { pcall(callback, ...) } local results: { any } = { pcall(callback, ...) }
setCurrentSub(prevSub) activeSub = prevSub
setCurrentScope(prevScope)
if not results[1] then if not results[1] then
error(results[2]) error(results[2])
end end
@ -465,16 +431,20 @@ end
--- @param args Arguments to pass to the callback. --- @param args Arguments to pass to the callback.
--- @return The return value of the callback. --- @return The return value of the callback.
local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results... local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
if not activeSub or bit32.btest(activeSub.flags, UNTRACKED) then if
return callback(...) activeSub
else and bit32.band(activeSub.flags, PEEKING) == 0
activeSub.flags = bit32.bor(activeSub.flags, UNTRACKED) and bit32.btest(activeSub.flags, bit32.bor(WATCHING, MUTABLE))
then
activeSub.flags = bit32.bor(activeSub.flags, PEEKING)
local results: { any } = { pcall(callback, ...) } local results: { any } = { pcall(callback, ...) }
activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(UNTRACKED)) activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(PEEKING))
if not results[1] then if not results[1] then
error(results[2]) error(results[2])
end end
return unpack(results, 2) return unpack(results, 2)
else
return callback(...)
end end
end end
@ -494,28 +464,51 @@ local function batched<Args..., Results...>(callback: (Args...) -> Results..., .
return unpack(results, 2) return unpack(results, 2)
end end
local function notify(e: ReactiveNode)
local flags = e.flags
if not bit32.btest(flags, QUEUED) then
e.flags = bit32.bor(flags, QUEUED)
local subs = e.subs
if subs then
notify(subs.sub)
else
queuedEffectsLength += 1
queuedEffects[queuedEffectsLength] = e :: Effect
end
end
end
local reactiveSystem = createReactiveSystem({ local reactiveSystem = createReactiveSystem({
update = update, update = function(node: ReactiveNode): boolean
if node.depsTail then
return updateComputed(node :: Computed)
else
return updateSignal(node :: Signal)
end
end,
notify = notify, notify = notify,
unwatched = unwatched, unwatched = function(node: ReactiveNode)
if bit32.band(node.flags, MUTABLE) == 0 then
effectScopeOper(node :: EffectScope)()
elseif node.depsTail then
node.depsTail = nil
node.flags = bit32.bor(MUTABLE, DIRTY)
purgeDeps(node)
end
end,
}) })
link = reactiveSystem.link link = reactiveSystem.link
unlink = reactiveSystem.unlink unlink = reactiveSystem.unlink
propagate = reactiveSystem.propagate propagate = reactiveSystem.propagate
checkDirty = reactiveSystem.checkDirty checkDirty = reactiveSystem.checkDirty
endTracking = reactiveSystem.endTracking
startTracking = reactiveSystem.startTracking
shallowPropagate = reactiveSystem.shallowPropagate shallowPropagate = reactiveSystem.shallowPropagate
return { return {
ReactiveFlags = ReactiveFlags, ReactiveFlags = ReactiveFlags,
createReactiveSystem = createReactiveSystem, createReactiveSystem = createReactiveSystem,
getCurrentSub = getCurrentSub, getActiveSub = getActiveSub,
setCurrentSub = setCurrentSub, setActiveSub = setActiveSub,
getCurrentScope = getCurrentScope,
setCurrentScope = setCurrentScope,
onCleanup = onCleanup,
getBatchDepth = getBatchDepth, getBatchDepth = getBatchDepth,
startBatch = startBatch, startBatch = startBatch,
endBatch = endBatch, endBatch = endBatch,
@ -523,6 +516,7 @@ return {
computed = computed, computed = computed,
effect = effect, effect = effect,
effectScope = effectScope, effectScope = effectScope,
onCleanup = onCleanup,
untracked = untracked, untracked = untracked,
peek = peek, peek = peek,
batched = batched, batched = batched,

View file

@ -1,7 +1,7 @@
local core = require("./core") local core = require("./core")
local computed = core.computed local computed = core.computed
local setCurrentSub = core.setCurrentSub local setActiveSub = core.setActiveSub
type Mapped = type Mapped =
& (<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) & (<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO })
@ -12,9 +12,9 @@ local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item:
local keyCache: { [KI]: KO } = {} local keyCache: { [KI]: KO } = {}
local function map(value: VI, key: KI): (VO, KO) local function map(value: VI, key: KI): (VO, KO)
local currentSub = setCurrentSub(nil) local prevSub = setActiveSub(nil)
local success, valueOut, keyOut = pcall(mapper, value, key) local success, valueOut, keyOut = pcall(mapper, value, key)
setCurrentSub(currentSub) setActiveSub(prevSub)
if not success then if not success then
error(`mapped() transform function failed: {valueOut}`) error(`mapped() transform function failed: {valueOut}`)

View file

@ -1,4 +1,4 @@
local function handleNoYield(thread: thread, ok: boolean, ...) local function noYieldHandler(thread: thread, ok: boolean, ...)
if not ok then if not ok then
return false, debug.traceback(thread, tostring((...))) return false, debug.traceback(thread, tostring((...)))
elseif coroutine.status(thread) ~= "dead" then elseif coroutine.status(thread) ~= "dead" then
@ -10,7 +10,7 @@ end
local function noYield<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...) local function noYield<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...)
local thread = coroutine.create(callback) local thread = coroutine.create(callback)
return handleNoYield(thread, coroutine.resume(thread, ...)) return noYieldHandler(thread, coroutine.resume(thread, ...))
end end
local function deepFreeze(object: any) local function deepFreeze(object: any)