diff --git a/README.md b/README.md index 8824d86..867b06c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/charm/__tests__/computed.test.luau b/packages/charm/__tests__/computed.test.luau index 59b8530..4590807 100644 --- a/packages/charm/__tests__/computed.test.luau +++ b/packages/charm/__tests__/computed.test.luau @@ -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() diff --git a/packages/charm/__tests__/effect.test.luau b/packages/charm/__tests__/effect.test.luau index c6a18f2..9b526c4 100644 --- a/packages/charm/__tests__/effect.test.luau +++ b/packages/charm/__tests__/effect.test.luau @@ -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) - effect(function() - setCounter(counter() + 1) +test("infinite recursion should eventually throw", function() + local counter, setCounter = signal(0) + + local success = pcall(function() + effect(function() + setCounter(counter() + 1) + end) end) - effect(function() - counter() - end) - setCounter(counter() + 1) + assert(not success, "should have thrown") end) return {} diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 46696e9..e7b3f1d 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -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(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( ): 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(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; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 0fbacc7..b3c4844 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -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) -> T) | T) -> T export type ReactiveProxy = typeof(setmetatable( @@ -21,12 +23,12 @@ export type ReactiveProxy = typeof(setmetatable( )) type EffectScopeNode = ReactiveNode & { - cleanups: { () -> () }, + cleanups: { Cleanup }, } type EffectNode = ReactiveNode & { - fn: () -> ...(() -> ())?, - cleanups: { () -> () }, + fn: () -> ...Cleanup?, + cleanups: { Cleanup }, } type ComputedNode = ReactiveNode & { @@ -39,16 +41,6 @@ type SignalNode = 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(this: ComputedNode): () -> 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( 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( 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( 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(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(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () +local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): Cleanup local tracked = toComputed(getter) local current: T @@ -838,9 +848,9 @@ local function listen(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(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> () +local function subscribe(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(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(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> () - local innerScopes: { [K]: () -> () } = {} +local function observe(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(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(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 diff --git a/tsconfig.json b/tsconfig.json index 6aa47d8..bf99b80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,6 @@ "jsxFragmentFactory": "React.Fragment", "module": "commonjs", "moduleResolution": "Node", - "ignoreDeprecations": "6.0", "noLib": true, "resolveJsonModule": true, "experimentalDecorators": true,