feat!: run listen and subscribe callback in untracked

This commit is contained in:
littensy 2026-03-19 18:42:30 -07:00
parent 50afcaf6e9
commit 5c27052965
11 changed files with 28 additions and 156 deletions

View file

@ -18,7 +18,7 @@
</div>
Charm is a state management library that brings the concept of [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) to Roblox. Connect behavior to data with reactive signals, ensuring games stay up-to-date with their underlying data while eliminating the need for manual updates.
Charm is a state management library based on [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf). Connect behavior to data with reactive signals, ensuring games stay up-to-date with their underlying data while eliminating the need for manual updates.
**Build game state from simple building blocks:**
@ -230,7 +230,7 @@ dispose() -- Cleanup 1
### Nested Effects
Effects can be nested inside other effects. When the outer effect re-runs, inner effects from the previous run are automatically cleaned up, and new inner effects are created if needed. The system ensures proper execution order; outer effects always run before their inner effects:
An effect is _nested_ if it was created during the execution of another effect. In Charm, when an effect with nested effects re-runs or gets cleaned up, the nested effects from the previous run are automatically cleaned up and re-created if needed. This prevents memory leaks and ensures that outer effects always run before their inner effects:
```luau
local getPrintCount, setPrintCount = signal(true)
@ -253,8 +253,6 @@ setPrintCount(false)
setCount(3) -- No output
```
Nested effect cleanup also applies to every reaction API (`subscribe()`, `listen()`, `observe()`) because they are built upon effects.
> [!NOTE]
> To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `flags.trackInnerEffects` flag to assist with debugging.
@ -332,11 +330,11 @@ end) -- Count is 0 (was nil)
setCounter(1) -- Count is 1 (was 0)
```
Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed.
Note that the listener callback runs in `untracked()`, so nested effects are not cleaned up.
### Getter functions
Reaction APIs like `listen()`, `subscribe()`, and `observe()` aren't only restricted to signals. You can listen to "getter" functions that call one or more signals, and they will automatically be memoized:
In most Charm APIs, you can also subscribe to getter functions that call one or more signals, and they will automatically be tracked:
```luau
local getCounter, setCounter = signal(0)
@ -375,7 +373,7 @@ setItems({ a = 0, c = 0 }) -- Removed b, Added c
The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed:
```luau
local getItems, setItems = signal({ a = 0, b = 0 })
local getItems, setItems = signal({})
local dispose = observe(getItems, function(value, key)
local getValue = computed(function(prevValue)
@ -384,14 +382,15 @@ local dispose = observe(getItems, function(value, key)
effect(function()
local value = getValue()
print(`{key} = {value}`)
print(`Set {key} = {value}`)
return function()
print(`Cleanup {key} = {value}`)
end
end)
end)
setItems({ a = 1, b = 0 }) -- Cleanup a = 0, a = 1
setItems({ a = 0, b = 0 }) -- Set a = 0, Set b = 0
setItems({ a = 1, b = 0 }) -- Cleanup a = 0, Set a = 1
setItems({ a = 1 }) -- Cleanup b = 0
dispose() -- Cleanup a = 1
```
@ -400,7 +399,7 @@ dispose() -- Cleanup a = 1
### `subscribe(getter, callback)`
The `subscribe` function is identical to `listen()`, but the effect callback skips the initial run. The callback only runs when the value returned by the getter function changes.
The `subscribe` function is identical to `listen()`, but the callback does not run initially. The callback only runs when the value returned by the getter function changes.
```luau
local getCounter, setCounter = signal(0)
@ -974,23 +973,15 @@ print(isReactive(raw)) -- false
## Migration
Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that might help you migrate from an older version.
Charm v0.11 introduces several breaking changes, so this section should help you migrate from an older version.
<details>
<summary><b>Terminology</b></summary>
- Signal: A state container with one function to get the state, and another to update it
- Atom: A signal with the getter and setter combined into one function
- Effect: Main way to react to state changes
- `subscribe(getter, callback)`: Creates an effect that only subscribes to signals accessed in the getter
</details>
For reference, a signal is a state container with a separated getter and setter function. Atoms are a signal with a single function for getting and setting the state.
**What to look out for:**
1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like:
- The second arguments of `atom()` changed from an `options` table to an equality function
- Removed the second argument of `computed()` (you can do your own equality checks now)
- Removed the second argument of `computed()` (you can do your own equality checks since the computed callback now receives the previous value)
- Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`)
2. If you use Charm Sync, you'll have to rewrite a lot of your sync code. Fortunately, most of the changes should make your code _less_ complicated:
@ -1003,14 +994,9 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
- `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations
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 the observer function in `observe()`, which also automatically cleans up inner effects to prevent memory leaks. However, this does not apply to `subscribe()` or `listen()`.
- Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback).
- 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 [`flags.trackInnerEffects`](#flags) 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`.
>
> 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 feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can temporarily disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`.
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)!
- [`signal()`](#signalinitialvalue-equals): make atom reads and writes more explicit

View file

@ -4,6 +4,6 @@ end
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
export type Atom<T> = Charm.Atom<T>
return Charm

View file

@ -809,7 +809,7 @@ local function listen<T>(getter: Getter<T>, fn: (state: T, lastState: T?) -> ())
return effect(function()
local previous = current
current = tracked()
peek(fn, current, previous)
untracked(fn, current, previous)
end)
end
@ -837,7 +837,7 @@ local function subscribe<T>(getter: Getter<T>, fn: (state: T, lastState: T) -> (
if firstRun then
firstRun = false
else
peek(fn, current, previous)
untracked(fn, current, previous)
end
end)
end

View file

@ -3,7 +3,6 @@ local test = require("../../../tests/test")
local signal = Charm.signal
local listen = Charm.listen
local effect = Charm.effect
test("should listen to selector", function()
local source, setSource = signal(0)
@ -26,32 +25,23 @@ test("should listen to selector", function()
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should run in peek", function()
test("should run in untracked", function()
local runs = 0
local cleanups = 0
local a, setA = signal(0)
local b, setB = signal(0)
listen(a, function()
runs += 1
b()
effect(function()
return function()
cleanups += 1
end
end)
end)
assert(runs == 1, "should run once on init")
assert(cleanups == 0, "should not cleanup yet")
setA(1)
assert(runs == 2, "should run on a change")
assert(cleanups == 1, "should cleanup first call")
assert(runs == 2, "should run once on change")
setB(1)
assert(runs == 2, "should not run on b change")
assert(cleanups == 1, "should not cleanup second call")
setA(2)
assert(runs == 3, "should run on a change")
end)
test("should run if first value is nil", function()
@ -67,36 +57,6 @@ test("should run if first value is nil", function()
assert(runs == 2, "should run once on change")
end)
test("should track inner subs", function()
local order = {}
local a, setA = signal(1)
local unsub = listen(a, function()
table.insert(order, 1)
listen(a, function()
table.insert(order, 2)
end)
listen(a, function()
table.insert(order, 3)
end)
end)
assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`)
table.clear(order)
setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
unsub()
setA(a() + 1)
assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
end)
test("should skip redundant updates", function()
local order = {}
local a, setA = signal(0)

View file

@ -4,8 +4,6 @@ local test = require("../../../tests/test")
local signal = Charm.signal
local computed = Charm.computed
local subscribe = Charm.subscribe
local listen = Charm.listen
local effect = Charm.effect
test("should subscribe to selector", function()
local source, setSource = signal(0)
@ -28,36 +26,23 @@ test("should subscribe to selector", function()
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should run in peek", function()
test("should run in untracked", function()
local runs = 0
local cleanups = 0
local a, setA = signal(0)
local b, setB = signal(0)
subscribe(a, function()
runs += 1
b()
effect(function()
return function()
cleanups += 1
end
end)
end)
assert(runs == 0, "should not run once on init")
assert(cleanups == 0, "should not cleanup yet")
setA(1)
assert(runs == 1, "should run once on change")
assert(cleanups == 0, "should not cleanup yet")
setB(1)
assert(runs == 1, "should not run on b change")
assert(cleanups == 0, "should not cleanup first call")
setA(2)
assert(runs == 2, "should run on a change")
assert(cleanups == 1, "should cleanup first call")
end)
test("should ensure correct execution order", function()
@ -86,33 +71,6 @@ test("should ensure correct execution order", function()
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
end)
test("should track inner subs", function()
local order = {}
local a, setA = signal(1)
local unsub = subscribe(a, function()
table.insert(order, 1)
listen(a, function()
table.insert(order, 2)
end)
listen(a, function()
table.insert(order, 3)
end)
end)
setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
unsub()
setA(a() + 1)
assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
end)
test("should skip redundant updates", function()
local order = {}
local a, setA = signal(0)

View file

@ -2,8 +2,4 @@ if not script then
return require("../charm/src")
end
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
return Charm
return require(script.Parent.Parent.charm.src)

View file

@ -1,5 +1 @@
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
return Charm
return require(script.Parent.Parent.charm.src)

View file

@ -1,12 +1 @@
type CoreReactBinding<T> = {
getValue: (self: CoreReactBinding<T>) -> T,
_source: string?,
}
type ReactBindingMap = {
map: <T, U>(self: CoreReactBinding<T> & ReactBindingMap, (T) -> U) -> ReactBindingMap & CoreReactBinding<U>,
}
export type Binding<T> = CoreReactBinding<T> & ReactBindingMap
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React)

View file

@ -17,10 +17,7 @@ local function useSignalState<T>(getter: () -> T, dependencies: { any }?): T
local value, setValue = React.useState(getter)
React.useLayoutEffect(function()
return Charm.effect(function()
-- Wrap in untracked to avoid tracking inner effect and atom calls
Charm.untracked(setValue, getter())
end)
return Charm.listen(getter, setValue)
end, dependencies or {})
return value
@ -42,10 +39,7 @@ local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?)
local binding, setBinding = React.useBinding(initialValue)
React.useLayoutEffect(function()
return Charm.effect(function()
-- Wrap in untracked to avoid tracking inner effect and atom calls
Charm.untracked(setBinding, getter())
end)
return Charm.listen(getter, setBinding)
end, dependencies or {})
return binding

View file

@ -1,5 +1 @@
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
return Charm
return require(script.Parent.Parent.charm.src)

View file

@ -11,10 +11,7 @@ local Vide = require("./Vide")
local function useSignalState<State>(getter: () -> State): () -> State
local state = Vide.source(getter())
Vide.cleanup(Charm.effect(function()
-- Wrap in untracked to avoid tracking inner effect and atom calls
Charm.untracked(state :: (State) -> State, getter())
end))
Vide.cleanup(Charm.listen(getter, state))
return state
end