mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat: add recursive()
This commit is contained in:
parent
96ab2317f2
commit
d890f4fc2c
8 changed files with 120 additions and 38 deletions
25
README.md
25
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
packages/charm/src/index.d.ts
vendored
63
packages/charm/src/index.d.ts
vendored
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue