feat: allow recursion in effects by default

This commit is contained in:
littensy 2026-02-22 23:14:50 -08:00
parent 10521d05c2
commit 612612b130
6 changed files with 104 additions and 126 deletions

View file

@ -56,7 +56,6 @@ Charm is a state management library that brings the [fine-grained reactivity](ht
- [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently)
- [`reactive(initialValue)`](#reactiveinitialvalue)
- [`atom(initialValue, equals?)`](#atominitialvalue-equals)
- [`recursive()`](#recursive)
- [`trigger(callback)`](#triggercallback)
- [`flags`](#flags)
- [Client-Server Sync](#client-server-sync)
@ -608,24 +607,6 @@ max(-1) -- 1
---
### `recursive()`
By design, Charm's reactive system [does not allow recursion](https://github.com/stackblitz/alien-signals/issues/90#issuecomment-3489711800) in effects or computed signals. In case you need to opt out of recursion checks for an effect, you can call `recursive()` at the top of the effect callback:
```luau
local getCounter, setCounter = signal(0)
effect(function()
recursive()
print(getCounter())
setCounter(function(count)
return math.min(count + 1, 3)
end)
end) -- Output: 0, 1, 2, 3
```
---
### `trigger(callback)`
The `trigger()` function allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter:
@ -990,10 +971,7 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
>
> This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI.
5. Recursion is now disallowed in effects and computed signals by default. This change may introduce bugs in code relying on the old behavior.
- Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback.
6. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)!
5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)!
- [`reactive()`](#reactiveinitialvalue): make mutable tables deeply reactive
- [`signal()`](#signalinitialvalue-equals): make reads and writes more explicit
- [`listen()`](#listengetter-callback): create a subscription that runs once immediately

View file

@ -145,17 +145,12 @@ end)
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L528
test("should not override queried MaybeDirty result", function()
local Item = {
new = function()
return { v = atom(0) }
end,
}
local v1 = atom(nil :: any)
local v2 = atom(false)
local c1 = computed(function()
local c = v1()
if not v1() then
c = Item.new()
c = { v = atom(0) }
v1(c)
end
return c.v()

View file

@ -6,7 +6,6 @@ local effect = Charm.effect
local effectScope = Charm.effectScope
local endBatch = Charm.endBatch
local onCleanup = Charm.onCleanup
local recursive = Charm.recursive
local setActiveSub = Charm.setActiveSub
local signal = Charm.signal
local startBatch = Charm.startBatch
@ -269,33 +268,11 @@ test("should handle flags are indirectly updated during checkDirty", function()
assert(triggers == 2, "did not update indirectly")
end)
test("should handle effect recursion for the first execution", function()
local src1, setSrc1 = signal(0)
local src2, setSrc2 = signal(0)
local triggers1 = 0
local triggers2 = 0
effect(function()
triggers1 += 1
setSrc1(math.min(src1() + 1, 5))
end)
effect(function()
triggers2 += 1
setSrc2(math.min(src2() + 1, 5))
src2()
end)
assert(triggers1 == 1, `bad triggers1: {triggers1}`)
assert(triggers2 == 1, `bad triggers2: {triggers2}`)
end)
test("should opt in to recursion", function()
test("should allow recursion", function()
local src, setSrc = signal(0)
local order = {}
effect(function()
recursive()
table.insert(order, src())
setSrc(math.min(src() + 1, 5))
end)
@ -387,15 +364,15 @@ test("should run cleanups outside any evaluation context", function()
assert(calls == 0, "should not run after b update")
end)
test("should avoid infinite recursive loops with multiple effects", function()
local counter, setCounter = Charm.signal(0)
test("infinite recursion should eventually throw", function()
local counter, setCounter = signal(0)
local success = pcall(function()
effect(function()
setCounter(counter() + 1)
end)
effect(function()
counter()
end)
setCounter(counter() + 1)
assert(not success, "should have thrown")
end)
return {}

View file

@ -153,10 +153,11 @@ export function trigger(fn: () => void): void;
export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
/**
* Runs the function without subscribing to signal updates or capturing inner
* effects in the parent effect or scope.
* Runs the function without subscribing to signal updates and prevents the
* current effect or scope from tracking inner effects created within the
* function.
*
* For an alternative that still cleans up inner effects, use `peek` instead.
* For an alternative that allows nested effect cleanup, use `peek` instead.
*
* @param fn A function that may read signals or create effects.
* @param args Arguments to pass to the function.
@ -166,10 +167,12 @@ export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
export function untracked<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
/**
* Runs the function without subscribing to signal updates, but still captures
* inner effects so that they dispose with the parent effect.
* Runs the function without subscribing to signal updates, but still allows
* inner effects to be tracked by parent effects/scopes.
*
* To avoid capturing inner effects, use `untracked` instead.
* This is useful for cases where you want to access the current value of a
* signal without creating a dependency, but still want any effects created
* within the function to be cleaned up properly.
*
* @param fn A function that may read signals.
* @param args Arguments to pass to the function.
@ -270,14 +273,15 @@ export function observe<V, K extends Key>(
): Cleanup;
/**
* Disables recursive checks for the current effect or computed signal.
* Useful for effects that intentionally update signals they depend on.
* Disables recursive checks for the current reactive node. Currently, only
* computed signals use recursion checks, as effects allow recursion.
*
* @example
* ```ts
* effect(() => {
* computed(() => {
* recursive();
* setCount(getCount() + 1);
* return getCount();
* })
* ```
*/
@ -331,10 +335,30 @@ export function isReactive(value: unknown): boolean;
*/
export function toRaw<T>(value: T): T;
/**
* Returns the currently active subscriber (effect or computed node).
*
* @return Current active subscriber or `nil`
*/
export function getActiveSub(): ReactiveNode | undefined;
/**
* Sets the currently active subscriber (effect or computed node) and returns
* the previous subscriber.
*
* @param sub New subscriber to set as active
* @return Previous active subscriber
*/
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
/**
* Starts a batch operation, incrementing the batch depth counter. Effects
* will not run until the outermost batch ends.
*/
export function startBatch(): void;
/**
* Ends a batch operation, decrementing the batch depth counter. If this is
* the outermost batch, queued effects are flushed and run.
*/
export function endBatch(): void;

View file

@ -9,6 +9,8 @@ export type Link = system.Link
export type ReactiveFlags = system.ReactiveFlags
export type ReactiveNode = system.ReactiveNode
type Cleanup = () -> ()
export type Atom<T = any> = (...((T) -> T) | T) -> T
export type ReactiveProxy<T = any> = typeof(setmetatable(
@ -21,12 +23,12 @@ export type ReactiveProxy<T = any> = typeof(setmetatable(
))
type EffectScopeNode = ReactiveNode & {
cleanups: { () -> () },
cleanups: { Cleanup },
}
type EffectNode = ReactiveNode & {
fn: () -> ...(() -> ())?,
cleanups: { () -> () },
fn: () -> ...Cleanup?,
cleanups: { Cleanup },
}
type ComputedNode<T = any> = ReactiveNode & {
@ -39,16 +41,6 @@ type SignalNode<T = any> = ReactiveNode & {
pendingValue: T,
}
-- Bitmask flags for tracking the state of reactive nodes. Flags are combined
-- and checked using bitwise operations for efficiency.
local NONE = 0b0000000 -- Default state
local MUTABLE = 0b0000001 -- Can be changed (signal or computed)
local WATCHING = 0b0000010 -- Watching for changes (effects)
local RECURSED_CHECK = 0b0000100 -- Being checked for circular dependencies
local DIRTY = 0b0010000 -- Value has changed and needs updating
local PENDING = 0b0100000 -- Might be dirty, pending verification
local PEEKING = 0b1000000 -- Blocking dependency tracking
local function inlineTest(): string
return debug.info(1, "n")
end
@ -79,6 +71,16 @@ local ReactiveProxy = {
proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }),
}
-- Bitmask flags for tracking the state of reactive nodes. Flags are combined
-- and checked using bitwise operations for efficiency.
local NONE = 0b0000000 -- Default state
local MUTABLE = 0b0000001 -- Can be changed (signal or computed)
local WATCHING = 0b0000010 -- Watching for changes (effects)
local RECURSED_CHECK = 0b0000100 -- Being checked for circular dependencies
local DIRTY = 0b0010000 -- Value has changed and needs updating
local PENDING = 0b0100000 -- Might be dirty, pending verification
local PEEKING = 0b1000000 -- Blocking dependency tracking
-- Alien Signals reactive system
local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> ()
local unlink: (link: Link, sub: ReactiveNode?) -> Link?
@ -187,7 +189,7 @@ end
@param cleanups List of cleanup functions
]]
local function runCleanups(cleanups: { () -> () })
local function runCleanups(cleanups: { Cleanup })
for key, cleanup in cleanups do
cleanups[key] = nil
local prevSub = setActiveSub(nil)
@ -212,13 +214,12 @@ local function run(effect: EffectNode)
cycle += 1
effect.depsTail = nil
effect.flags = bit32.bor(WATCHING, RECURSED_CHECK)
effect.flags = WATCHING
local prevSub = setActiveSub(effect)
local success, result = pcall(effect.fn)
activeSub = prevSub
effect.flags = bit32.band(effect.flags, bit32.bnot(RECURSED_CHECK))
purgeDeps(effect)
if not success then
@ -349,10 +350,13 @@ local function computedOper<T>(this: ComputedNode<T>): () -> T
elseif flags == NONE then
-- First access should initialize the computed value
this.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
local prevSub = setActiveSub(this)
local success, result = pcall(this.getter)
activeSub = prevSub
this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK))
if success then
this.value = result
else
@ -393,6 +397,7 @@ local function signalOper<T>(
local prevSub = setActiveSub(nil)
local success, result = pcall(noYield(update), this.pendingValue)
activeSub = prevSub
if success then
value = result
else
@ -407,8 +412,10 @@ local function signalOper<T>(
if flags.frozen then
deepFreeze(value)
end
this.pendingValue = value
this.flags = bit32.bor(MUTABLE, DIRTY)
local subs = this.subs
if subs then
propagate(subs)
@ -416,6 +423,7 @@ local function signalOper<T>(
flush()
end
end
return value
end
@ -462,7 +470,7 @@ end
@param this Effect node
@return Effect cleanup operator
]]
local function effectOper(this: EffectScopeNode): () -> ()
local function effectOper(this: EffectScopeNode): Cleanup
return function()
this.depsTail = nil
this.flags = NONE
@ -511,16 +519,15 @@ local function notify(effect: ReactiveNode)
until false
queuedLength = insertIndex
insertIndex -= 1
-- Reverse the collected effects to maintain correct order
-- Note: insertIndex should decrement before the next condition evaluation
while firstInsertedIndex < insertIndex do
local left = queued[firstInsertedIndex]
queued[firstInsertedIndex] = queued[insertIndex]
queued[insertIndex] = left
firstInsertedIndex += 1
-- Reverse the collected effects to ensure outer effects run first
while true do
insertIndex -= 1
if firstInsertedIndex >= insertIndex then
break
end
queued[firstInsertedIndex], queued[insertIndex] = queued[insertIndex], queued[firstInsertedIndex]
firstInsertedIndex += 1
end
end
@ -615,10 +622,10 @@ end
@return A cleanup function that stops the effect
@see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
]]
local function effect(fn: () -> ...(() -> ())?): () -> ()
local function effect(fn: () -> ...Cleanup?): Cleanup
local this: EffectNode = {
fn = noYield(fn),
flags = bit32.bor(WATCHING, RECURSED_CHECK),
flags = WATCHING,
cleanups = {},
}
@ -630,8 +637,6 @@ local function effect(fn: () -> ...(() -> ())?): () -> ()
local success, result = pcall(this.fn)
activeSub = prevSub
this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK))
if not success then
error(result)
elseif result then
@ -650,7 +655,7 @@ end
@return A cleanup function that stops all effects in the scope
@see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
]]
local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> ()
local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup
local this: EffectScopeNode = {
flags = NONE,
cleanups = {},
@ -719,7 +724,7 @@ end
@param failSilently If true, silences the warning when called outside an effect/scope
@see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
]]
local function onCleanup(fn: () -> (), failSilently: boolean?)
local function onCleanup(fn: Cleanup, failSilently: boolean?)
if activeSub and (activeSub :: EffectNode).cleanups then
table.insert((activeSub :: EffectNode).cleanups, noYield(fn))
elseif not failSilently then
@ -728,10 +733,11 @@ local function onCleanup(fn: () -> (), failSilently: boolean?)
end
--[[
Runs the function without subscribing to signal updates or capturing inner
effects in the parent effect or scope.
Runs the function without subscribing to signal updates and prevents the
current effect or scope from tracking inner effects created within the
function.
For an alternative that still cleans up inner effects, use `peek` instead.
For an alternative that allows nested effect cleanup, use `peek` instead.
@param fn A signal or function that accesses signals
@param ...args Arguments to pass to the function
@ -753,8 +759,12 @@ local function untracked<Args..., Results...>(fn: (Args...) -> Results..., ...:
end
--[[
Runs the function without subscribing to signal updates, but still captures
inner effects so that they dispose with the parent effect.
Runs the function without subscribing to signal updates, but still allows
inner effects to be tracked by parent effects/scopes.
This is useful for cases where you want to access the current value of a
signal without creating a dependency, but still want any effects created
within the function to be cleaned up properly.
To avoid capturing inner effects, use `untracked` instead.
@ -815,8 +825,8 @@ end
--[[
Creates an effect that runs the callback one immediately, and then again
when the value returned by the getter changes. The callback receives the new
value and the previous value as arguments.
when the value returned by the getter changes. The callback receives the
new value and the previous value as arguments.
Effects created within the callback are cleaned up when the listener runs
or the listener is disposed.
@ -826,7 +836,7 @@ end
@return A cleanup function that stops the effect
@see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
]]
local function listen<T>(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> ()
local function listen<T>(getter: () -> T, fn: (state: T, previous: T?) -> ()): Cleanup
local tracked = toComputed(getter)
local current: T
@ -838,9 +848,9 @@ local function listen<T>(getter: () -> T, fn: (state: T, previous: T?) -> ()): (
end
--[[
Creates an effect that only runs the callback when the value returned by the
getter changes. The callback receives the new value and the previous value
as arguments.
Creates an effect that only runs the callback when the value returned by
the getter changes. The callback receives the new value and the previous
value as arguments.
Effects created within the callback are cleaned up when the listener runs
or the listener is disposed.
@ -850,7 +860,7 @@ end
@return A cleanup function that stops the effect
@see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
]]
local function subscribe<T>(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> ()
local function subscribe<T>(getter: () -> T, fn: (state: T, lastState: T) -> ()): Cleanup
local tracked = toComputed(getter)
local current: T
local firstRun = true
@ -867,14 +877,15 @@ local function subscribe<T>(getter: () -> T, fn: (state: T, lastState: T) -> ())
end
--[[
Disables recursive checks for the current effect or computed signal. Useful
for effects that intentionally update signals they depend on.
Disables recursive checks for the current reactive node. Currently, only
computed signals use recursion checks, as effects allow recursion.
@example
```luau
effect(function()
computed(function()
recursive()
setCount(getCount() + 1)
return getCount()
end)
```
]]
@ -882,7 +893,7 @@ local function recursive()
if activeSub then
activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK))
else
warn(debug.traceback("recursive() can only be called inside an effect or computed signal.", 2))
warn(debug.traceback("recursive() can only be called inside a reactive node.", 2))
end
end
@ -900,8 +911,8 @@ end
@return A cleanup function that disposes scopes for all keys
@see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
]]
local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> ()
local innerScopes: { [K]: () -> () } = {}
local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...Cleanup?): Cleanup
local innerScopes: { [K]: Cleanup } = {}
local disposed = false
local function updateScopes(data: { [K]: V })
@ -930,9 +941,6 @@ local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) ->
return effectScope(function()
effect(function()
-- Allow recursive updates in case the callback creates or removes
-- items in the observed table.
recursive()
-- Batch so that side effects run after all scopes are created.
-- This ensures that a scope creating or removing items doesn't
-- interfere with the current update cycle.
@ -940,9 +948,6 @@ local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) ->
end)
return function()
if disposed then
return
end
disposed = true
for key, dispose in innerScopes do
innerScopes[key] = nil

View file

@ -8,7 +8,6 @@
"jsxFragmentFactory": "React.Fragment",
"module": "commonjs",
"moduleResolution": "Node",
"ignoreDeprecations": "6.0",
"noLib": true,
"resolveJsonModule": true,
"experimentalDecorators": true,