feat: add recursive()

This commit is contained in:
littensy 2025-11-05 02:30:41 -08:00
parent 96ab2317f2
commit d890f4fc2c
8 changed files with 120 additions and 38 deletions

View file

@ -540,16 +540,33 @@ 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) --> 0, 1, 2, 3
```
---
### `globals`
You can customize the behavior of Charm through the `globals` table:
| Flag | Default | Description |
| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. |
| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. |
| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. |
| allowRecursion | `false` | Allows recursion in effects and computed signals, which is intentionally disallowed in the [alien-signals](https://github.com/stackblitz/alien-signals) reactive system. |
| Flag | Default | Description |
| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. |
| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. |
| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. |
The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio.
@ -831,7 +848,7 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent.
- This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`.
- Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback).
- This feature can introduce runtime bugs in legacy code. If you need to narrow down a cause, you can disable this feature by setting [`globals.trackInnerEffects`](#globals) to `false`.
- This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`globals.trackInnerEffects`](#globals) to `false`.
> [!NOTE]
> An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`.
@ -839,7 +856,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 disallowed in effects and computed signals now that Charm uses the [alien-signals algorithm](https://github.com/stackblitz/alien-signals). This change may introduce bugs in code relying on the old behavior.
- You can disable recursion checks by setting [`globals.allowRecursion`](#globals) to `true`, but try to consider other options first, as recursion isn't fully supported and is not well optimized.
- 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)!
- Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit

View file

@ -6,6 +6,7 @@ local effect = core.effect
local effectScope = core.effectScope
local endBatch = core.endBatch
local onCleanup = core.onCleanup
local recursive = core.recursive
local setActiveSub = core.setActiveSub
local signal = core.signal
local startBatch = core.startBatch
@ -290,6 +291,18 @@ test("should handle effect recursion for the first execution", function()
assert(triggers2 == 1, `bad triggers2: {triggers2}`)
end)
test("should opt in to recursion", function()
local src, setSrc = signal(0)
local order = {}
effect(function()
recursive()
table.insert(order, src())
setSrc(math.min(src() + 1, 5))
end)
assert(table.concat(order) == "012345", `bad order: {table.concat(order)}`)
end)
test("should run cleanups in sequence", function()
local order = {}

View file

@ -1,4 +1,3 @@
local globals = require("./globals")
--[[
Reactive system adapted from alien-signals at
https://github.com/stackblitz/alien-signals/blob/master/src/system.ts
@ -168,17 +167,10 @@ local function createReactiveSystem(props: {
end
local function propagate(link: Link): ()
local allowRecursion = globals.allowRecursion
repeat
local sub = link.sub
local flags = sub.flags
if allowRecursion then
flags = bit32.band(flags, bit32.bnot(bit32.bor(RECURSED, RECURSED_CHECK)))
sub.flags = flags
end
if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
sub.flags = bit32.bor(flags, PENDING)
elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then

View file

@ -555,6 +555,24 @@ local function subscribe<T>(getter: () -> T, callback: (state: T, lastState: T)
end)
end
--- Disables recursive checks for the current effect or computed signal. Useful
--- for effects that intentionally update signals they depend on.
---
--- @example
--- ```luau
--- effect(function()
--- recursive()
--- setCount(getCount() + 1)
--- end)
--- ```
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))
end
end
local reactiveSystem = createReactiveSystem({
update = function(node)
if node.depsTail then
@ -626,4 +644,5 @@ return {
batch = batch,
subscribe = subscribe,
listen = listen,
recursive = recursive,
}

View file

@ -17,8 +17,4 @@ return {
--- when the parent effect re-runs. This is usually desirable, but can be
--- disabled to revert to the old behavior. Defaults to `true`.
trackInnerEffects = true,
--- Whether to allow recursive updates in effects and computed signals.
--- Disabling this can help catch certain classes of bugs, but may also
--- cause valid code to error. Defaults to `false`.
allowRecursion = false,
}

View file

@ -36,12 +36,6 @@ export const globals: {
* disabled to revert to the old behavior. Defaults to `true`.
*/
trackInnerEffects: boolean;
/**
* Whether to allow recursive updates in effects and computed signals.
* Disabling this can help catch certain classes of bugs, but may also
* cause valid code to error. Defaults to `false`.
*/
allowRecursion: boolean;
};
/**
@ -238,3 +232,60 @@ export function observe<V, K extends Key>(
getter: () => { readonly [K in Key]: V },
callback: (value: V, key: K) => Cleanup | void,
): Cleanup;
export namespace core {
export { signal, effect, effectScope, atom, computed, onCleanup, untracked, peek, batch, listen };
export enum ReactiveFlags {
None = 0b0000000,
Mutable = 0b0000001,
Watching = 0b0000010,
RecursedCheck = 0b0000100,
Recursed = 0b0001000,
Dirty = 0b0010000,
Pending = 0b0100000,
Peeking = 0b1000000,
}
export interface ReactiveNode {
deps?: Link;
depsTail?: Link;
subs?: Link;
subsTail?: Link;
flags: ReactiveFlags;
}
export interface Link {
version: number;
dep: ReactiveNode;
sub: ReactiveNode;
prevSub?: Link;
nextSub?: Link;
prevDep?: Link;
nextDep?: Link;
}
/**
* Disables recursive checks for the current effect or computed signal.
* Useful for effects that intentionally update signals they depend on.
*
* @example
* ```ts
* effect(() => {
* recursive();
* setCount(getCount() + 1);
* })
* ```
*/
export function recursive(): void;
export function getActiveSub(): ReactiveNode | undefined;
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
export function getBatchDepth(): number;
export function startBatch(): void;
export function endBatch(): void;
}

View file

@ -33,6 +33,7 @@ return {
listen = core.listen,
onCleanup = core.onCleanup,
peek = core.peek,
recursive = core.recursive,
signal = core.signal,
subscribe = core.subscribe,
untracked = core.untracked,

View file

@ -1,10 +1,9 @@
local core = require("./core")
type ReactiveNode = core.ReactiveNode
local batch = core.batch
local effect = core.effect
local effectScope = core.effectScope
local recursive = core.recursive
--- Calls the callback function for each unique key in the table returned by the
--- getter. The callback is called once for each key, and again when a new key
@ -42,19 +41,13 @@ local function observe<K, V>(getter: () -> { [K]: V }, callback: (item: V, key:
return effectScope(function()
effect(function()
local data = getter()
-- 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.
batch(updateScopes, data)
-- Effects can't trigger themselves, so if a scope creates or
-- removes items, we need to check for updates again.
local newData = getter()
if newData ~= data then
batch(updateScopes, newData)
end
batch(updateScopes, getter())
end)
return function()