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