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` ### `globals`
You can customize the behavior of Charm through the `globals` table: You can customize the behavior of Charm through the `globals` table:
| Flag | Default | Description | | Flag | Default | Description |
| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | | 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. | | 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. | | 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. |
The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio. 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. 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()`. - 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). - 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] > [!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`. > 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. > 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. 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)! 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 - 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 effectScope = core.effectScope
local endBatch = core.endBatch local endBatch = core.endBatch
local onCleanup = core.onCleanup local onCleanup = core.onCleanup
local recursive = core.recursive
local setActiveSub = core.setActiveSub local setActiveSub = core.setActiveSub
local signal = core.signal local signal = core.signal
local startBatch = core.startBatch local startBatch = core.startBatch
@ -290,6 +291,18 @@ test("should handle effect recursion for the first execution", function()
assert(triggers2 == 1, `bad triggers2: {triggers2}`) assert(triggers2 == 1, `bad triggers2: {triggers2}`)
end) 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() test("should run cleanups in sequence", function()
local order = {} local order = {}

View file

@ -1,4 +1,3 @@
local globals = require("./globals")
--[[ --[[
Reactive system adapted from alien-signals at Reactive system adapted from alien-signals at
https://github.com/stackblitz/alien-signals/blob/master/src/system.ts https://github.com/stackblitz/alien-signals/blob/master/src/system.ts
@ -168,17 +167,10 @@ local function createReactiveSystem(props: {
end end
local function propagate(link: Link): () local function propagate(link: Link): ()
local allowRecursion = globals.allowRecursion
repeat repeat
local sub = link.sub local sub = link.sub
local flags = sub.flags 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 if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
sub.flags = bit32.bor(flags, PENDING) sub.flags = bit32.bor(flags, PENDING)
elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then 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)
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({ local reactiveSystem = createReactiveSystem({
update = function(node) update = function(node)
if node.depsTail then if node.depsTail then
@ -626,4 +644,5 @@ return {
batch = batch, batch = batch,
subscribe = subscribe, subscribe = subscribe,
listen = listen, listen = listen,
recursive = recursive,
} }

View file

@ -17,8 +17,4 @@ return {
--- when the parent effect re-runs. This is usually desirable, but can be --- when the parent effect re-runs. This is usually desirable, but can be
--- disabled to revert to the old behavior. Defaults to `true`. --- disabled to revert to the old behavior. Defaults to `true`.
trackInnerEffects = 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`. * disabled to revert to the old behavior. Defaults to `true`.
*/ */
trackInnerEffects: boolean; 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 }, getter: () => { readonly [K in Key]: V },
callback: (value: V, key: K) => Cleanup | void, callback: (value: V, key: K) => Cleanup | void,
): Cleanup; ): 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, listen = core.listen,
onCleanup = core.onCleanup, onCleanup = core.onCleanup,
peek = core.peek, peek = core.peek,
recursive = core.recursive,
signal = core.signal, signal = core.signal,
subscribe = core.subscribe, subscribe = core.subscribe,
untracked = core.untracked, untracked = core.untracked,

View file

@ -1,10 +1,9 @@
local core = require("./core") local core = require("./core")
type ReactiveNode = core.ReactiveNode
local batch = core.batch local batch = core.batch
local effect = core.effect local effect = core.effect
local effectScope = core.effectScope local effectScope = core.effectScope
local recursive = core.recursive
--- Calls the callback function for each unique key in the table returned by the --- 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 --- 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() return effectScope(function()
effect(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. -- Batch so that side effects run after all scopes are created.
-- This ensures that a scope creating or removing items doesn't -- This ensures that a scope creating or removing items doesn't
-- interfere with the current update cycle. -- interfere with the current update cycle.
batch(updateScopes, data) batch(updateScopes, getter())
-- 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
end) end)
return function() return function()