From 5c27052965e9a15cf45c9273a0a87111b42af3e2 Mon Sep 17 00:00:00 2001
From: littensy <56808540+littensy@users.noreply.github.com>
Date: Thu, 19 Mar 2026 18:42:30 -0700
Subject: [PATCH] feat!: run listen and subscribe callback in untracked
---
README.md | 42 ++++++++--------------
packages/charm-sync/Charm.luau | 2 +-
packages/charm/src/init.luau | 4 +--
packages/charm/test/listen.test.luau | 48 +++----------------------
packages/charm/test/subscribe.test.luau | 44 +----------------------
packages/deep-charm/Charm.luau | 6 +---
packages/react-charm/Charm.luau | 6 +---
packages/react-charm/React.luau | 11 ------
packages/react-charm/src/init.luau | 10 ++----
packages/vide-charm/Charm.luau | 6 +---
packages/vide-charm/src/init.luau | 5 +--
11 files changed, 28 insertions(+), 156 deletions(-)
diff --git a/README.md b/README.md
index 4c5a3f7..dec25b6 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
-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.
-
-Terminology
-
-- 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
-
-
+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
diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau
index 9a55fe0..480f2d5 100644
--- a/packages/charm-sync/Charm.luau
+++ b/packages/charm-sync/Charm.luau
@@ -4,6 +4,6 @@ end
local Charm = require(script.Parent.Parent.charm.src)
-export type Atom = Charm.Atom
+export type Atom = Charm.Atom
return Charm
diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau
index ac90a3d..1342c99 100644
--- a/packages/charm/src/init.luau
+++ b/packages/charm/src/init.luau
@@ -809,7 +809,7 @@ local function listen(getter: Getter, 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(getter: Getter, fn: (state: T, lastState: T) -> (
if firstRun then
firstRun = false
else
- peek(fn, current, previous)
+ untracked(fn, current, previous)
end
end)
end
diff --git a/packages/charm/test/listen.test.luau b/packages/charm/test/listen.test.luau
index 6fa0912..5d9c335 100644
--- a/packages/charm/test/listen.test.luau
+++ b/packages/charm/test/listen.test.luau
@@ -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)
diff --git a/packages/charm/test/subscribe.test.luau b/packages/charm/test/subscribe.test.luau
index 9c02fd0..6936c7a 100644
--- a/packages/charm/test/subscribe.test.luau
+++ b/packages/charm/test/subscribe.test.luau
@@ -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)
diff --git a/packages/deep-charm/Charm.luau b/packages/deep-charm/Charm.luau
index 9a55fe0..619a57a 100644
--- a/packages/deep-charm/Charm.luau
+++ b/packages/deep-charm/Charm.luau
@@ -2,8 +2,4 @@ if not script then
return require("../charm/src")
end
-local Charm = require(script.Parent.Parent.charm.src)
-
-export type Atom = Charm.Atom
-
-return Charm
+return require(script.Parent.Parent.charm.src)
diff --git a/packages/react-charm/Charm.luau b/packages/react-charm/Charm.luau
index 9588118..49baa5a 100644
--- a/packages/react-charm/Charm.luau
+++ b/packages/react-charm/Charm.luau
@@ -1,5 +1 @@
-local Charm = require(script.Parent.Parent.charm.src)
-
-export type Atom = Charm.Atom
-
-return Charm
+return require(script.Parent.Parent.charm.src)
diff --git a/packages/react-charm/React.luau b/packages/react-charm/React.luau
index dd59e59..1c7cfd8 100644
--- a/packages/react-charm/React.luau
+++ b/packages/react-charm/React.luau
@@ -1,12 +1 @@
-type CoreReactBinding = {
- getValue: (self: CoreReactBinding) -> T,
- _source: string?,
-}
-
-type ReactBindingMap = {
- map: (self: CoreReactBinding & ReactBindingMap, (T) -> U) -> ReactBindingMap & CoreReactBinding,
-}
-
-export type Binding = CoreReactBinding & ReactBindingMap
-
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React)
diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau
index b626b3f..205514a 100644
--- a/packages/react-charm/src/init.luau
+++ b/packages/react-charm/src/init.luau
@@ -17,10 +17,7 @@ local function useSignalState(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(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
diff --git a/packages/vide-charm/Charm.luau b/packages/vide-charm/Charm.luau
index 9588118..49baa5a 100644
--- a/packages/vide-charm/Charm.luau
+++ b/packages/vide-charm/Charm.luau
@@ -1,5 +1 @@
-local Charm = require(script.Parent.Parent.charm.src)
-
-export type Atom = Charm.Atom
-
-return Charm
+return require(script.Parent.Parent.charm.src)
diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau
index 073e759..db9d2e7 100644
--- a/packages/vide-charm/src/init.luau
+++ b/packages/vide-charm/src/init.luau
@@ -11,10 +11,7 @@ local Vide = require("./Vide")
local function useSignalState(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