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

@ -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()