From 37f79ecc3f476f50eb060445a3737f5571b10105 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Sun, 2 Nov 2025 18:55:41 -0800 Subject: [PATCH] refactor: remove unnecessary features, update docs --- README.md | 952 ++++++++---------- packages/charm-sync/src/index.d.ts | 17 +- packages/charm-sync/src/server.luau | 84 +- packages/charm-sync/src/utils.luau | 7 + .../charm/src/__tests__/core/effect.spec.luau | 21 - packages/charm/src/__tests__/listen.spec.luau | 21 - packages/charm/src/core.luau | 159 ++- packages/charm/src/globals.luau | 4 +- packages/charm/src/index.d.ts | 223 ++-- packages/charm/src/mapped.luau | 36 +- packages/charm/src/observe.luau | 19 +- packages/react-charm/src/__tests__/spec.luau | 8 +- ...g.spec.luau => useSignalBinding.spec.luau} | 28 +- ...tom.spec.luau => useSignalState.spec.luau} | 28 +- packages/react-charm/src/index.d.ts | 30 +- packages/react-charm/src/init.luau | 28 +- packages/vide-charm/src/__tests__/spec.luau | 4 +- ...tom.spec.luau => useSignalState.spec.luau} | 36 +- packages/vide-charm/src/index.d.ts | 13 +- packages/vide-charm/src/init.luau | 11 +- 20 files changed, 774 insertions(+), 955 deletions(-) rename packages/react-charm/src/__tests__/{useAtomBinding.spec.luau => useSignalBinding.spec.luau} (67%) rename packages/react-charm/src/__tests__/{useAtom.spec.luau => useSignalState.spec.luau} (65%) rename packages/vide-charm/src/__tests__/{useAtom.spec.luau => useSignalState.spec.luau} (59%) diff --git a/README.md b/README.md index e3a421f..01d0b60 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Charm

- Atomic state management for Roblox. + Manage state with reactive signals
npm package →

@@ -18,25 +18,96 @@ -**Charm** is an atomic and immutable state management library, inspired by [Jotai](https://jotai.org) and [Nanostores](https://github.com/nanostores/nanostores). Store your state in atoms, and write your own functions to read, write, and observe state. +**Charm** is a state management library that aims to bring [reactive signals](https://preactjs.com/blog/signal-boosting/) from libraries like Preact and Solid to Roblox. It's built on the [alien-signals](https://github.com/stackblitz/alien-signals) signal algorithm, expanding upon it further to handle a wide range of use cases, from rendering user interfaces to handling server game logic. -See an example of Charm's features in [this example repository](https://github.com/littensy/charm-example). +**Charm has a few core principles:** -## 🍀 Features +- Manage state with reactive signals: state containers that hold a value +- React to state updates: reading a signal automatically subscribes to it +- Combine multiple signals: derive a new value from existing state that stays up-to-date +- Fine-grained reactivity: make targeted updates in response to specific changes in the state +- Use immutable data: values are compared directly (`==`) to optimize change detection -- ⚛️ **Manage state with _atoms_.** Decompose state into independent containers called _atoms_, as opposed to combining them into a single store. +**Want to learn more about signals?** -- 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state. +- https://preactjs.com/blog/signal-boosting +- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity +- https://angular.dev/guide/signals -- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness. +## At a Glance -- 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you. +```luau +local getTodos, setTodos = signal({} :: { [number]: string }) +local getQuery, setQuery = signal("") ---- +observe(getTodos, function(todo, index) + local instance = Instance.new("TextLabel") -## 📦 Setup + local getText = computed(function(prevText) + return getTodos()[index] or prevText + end) -Install Charm for roblox-ts using your package manager of choice. + effect(function() + instance.Text = getText() + instance.Visible = getText():match(getQuery()) ~= nil + end) + + instance.LayoutOrder = index + instance.Size = UDim2.new(1, 0, 0, 40) + instance.Parent = screenGui + + return function() + instance:Destroy() + end +end) + +setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) +setQuery("^Buy") +``` + +
+Explain code + +```luau +local getTodos, setTodos = signal({} :: { [number]: string }) +local getQuery, setQuery = signal("") + +-- Create a text label when an item is added to the list +observe(getTodos, function(todo, index) + local instance = Instance.new("TextLabel") + + -- Create a computed signal that stores the current text, or the previous + -- value if the item was removed + local getText = computed(function(prevText) + return getTodos()[index] or prevText + end) + + -- Set properties when the text or query updates + effect(function() + instance.Text = getText() + instance.Visible = getText():match(getQuery()) ~= nil + end) + + instance.LayoutOrder = index + instance.Size = UDim2.new(1, 0, 0, 40) + instance.Parent = screenGui + + -- Destroy the instance when this item is removed + return function() + instance:Destroy() + end +end) + +-- Add items to the todo list +setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) +-- Filter for items starting with "Buy" +setQuery("^Buy") +``` + + +
+ +## Installation ```sh npm install @rbxts/charm @@ -44,8 +115,6 @@ yarn add @rbxts/charm pnpm add @rbxts/charm ``` -Alternatively, add `littensy/charm` to your `wally.toml` file. - ```toml [dependencies] Charm = "littensy/charm@VERSION" @@ -53,653 +122,432 @@ Charm = "littensy/charm@VERSION" --- -## 🐛 Debugging +## Guide -Charm provides a debug mode to help you identify potential bugs in your project. To enable debug mode, set the global `_G.__DEV__` flag to `true` before importing Charm. +### `signal(initialValue, equals?)` -Enabling `__DEV__` adds a few helpful features: +Signals are the core of reactivity in Charm. The `signal` function creates a reactive signal that acts as a container for a value. It returns a function to access the value, and another to update the value. -- Better error handling for selectors, subscriptions, and batched functions: +```luau +local getCounter, setCounter = signal(0) - - Errors provide the function's name and line number. - - Yielding in certain functions will throw an error. +print(getCounter()) --> 0 +setCounter(1) +setCounter(function(count) + return count + 1 +end) +``` -- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client. +Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately update every effect and computed signal that depends signal, ensuring every part of your state is correct and up-to-date. -Enabling debug mode in unit tests, storybooks, and other development environments can help you catch potential issues early. However, remember to turn off debug mode in production to avoid the performance overhead. +You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value: + +```luau +local getMax, setMax = signal(0, function(incoming, current) + return incoming <= current +end) + +setMax(1) --> 1 +setMax(-1) --> 1 +``` + +> [!NOTE] +> Looking for atoms? You can still use `atom()` to create a signal with a combined getter and setter. To use signals for APIs that require atoms, use `signalToAtom()`. --- -## 📚 Reference +### `computed(getter)` -### `atom(state, options?)` +The `computed` function creates a new signal with a value derived from multiple signals. The computed signal can be reacted to like a normal signal, and it returns the getter function's last return value. -Atoms are the building blocks of Charm. They are functions that hold a single value, and calling them can read or write to that value. Atoms, or any function that reads from atoms, can also be [subscribed](#subscribecallback-listener) to. - -Call `atom` to create a state container initialized with the value `state`. +The computed signal will subscribe to signals accessed by the getter and track them as dependencies. Changes to these dependencies will re-execute the getter, and if it returns a new value, then the computed signal is updated to that value. ```luau -local nameAtom = atom("John") -local todosAtom: Atom<{ string }> = atom({}) -``` - -#### Parameters - -- `state`: The value to assign to the atom initially. - -- **optional** `options`: An object that configures the behavior of this atom. - - - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`==`) is used. - -#### Returns - -The `atom` constructor returns an atom function with two possible operations: - -1. **Read the state.** Call the atom without arguments to get the current state. -2. **Set the state.** Pass a new value or an updater function to change the state. - -```luau -local function newTodo() - nameAtom("Jane") - - todosAtom(function(todos) - todos = table.clone(todos) - table.insert(todos, "Buy milk") - return todos - end) - - print(nameAtom()) --> "Jane" -end -``` - ---- - -### `subscribe(callback, listener)` - -Call `subscribe` to listen for changes in an atom or selector function. When the function's result changes, subscriptions are immediately called with the new state and the previous state. - -```luau -local nameAtom = atom("John") - -local cleanup = subscribe(nameAtom, function(name, prevName) - print(name) +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +local getFullName = computed(function() + return `{getName()} {getSurname()}` end) -nameAtom("Jane") --> "Jane" +print(getFullName()) --> "John Doe" +setName("Jane") +print(getFullName()) --> "Jane Doe" ``` -You may also pass a selector function that calls other atoms. The function will be memoized and only runs when its atom dependencies update. +The getter function also receives the previous state (or `nil` if running for the first time). You can use this for more complex state: ```luau -local function getUppercase() - return string.upper(nameAtom()) -end - -local cleanup = subscribe(getUppercase, function(name) - print(name) +local getCounter, setCounter = signal(10) +local getMax = computed(function(prevMax) + return math.max(getCounter(), prevMax or 0) end) -nameAtom("Jane") --> "JANE" +print(getMax()) --> 10 +setCounter(5) +print(getMax()) --> 10 ``` -#### Parameters - -- `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom. - -- `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments. - -#### Returns - -`subscribe` returns a cleanup function. +Computed signals are updated lazily, meaning the getter will only re-execute once the computed signal is called _and_ if a dependency updated since the last call. --- ### `effect(callback)` -Call `effect` to track state changes in all atoms read within the callback. The callback will run once to retrieve its dependencies, and then again whenever they change. Your callback may return a cleanup function to run when the effect is removed or about to re-run. +Effects are fundamental to reactivity, allowing you to react to signal updates. The `effect` function subscribes to signals accessed by the effect callback, and when a dependency updates, the callback will re-execute. ```luau -local nameAtom = atom("John") +local getCounter, setCounter = signal(0) -local cleanup = effect(function() - print(nameAtom()) +effect(function() + print(`Count is {getCounter()}`) +end) --> Count is 0 + +setCounter(1) --> Count is 1 +``` + +You can also return a cleanup function that will run once, either before the effect re-runs or when the effect is disposed: + +```luau +local getCounter, setCounter = signal(0) + +local dispose = effect(function() + local count = getCounter() return function() - print("Cleanup function called!") + print(`Cleanup {count}`) end end) + +setCounter(1) --> Cleanup 0 +dispose() --> Cleanup 1 ``` -Because `effect` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies. - -#### Parameters - -- `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change. - -#### Returns - -`effect` returns a cleanup function. - -#### Caveats - -- **If your effect should disconnect itself, use the `cleanup` argument.** Because effects run immediately, your effect may run before a `cleanup` function is returned. To disconnect an effect from the inside, use the argument passed to your effect instead: - ```lua - effect(function(cleanup) - if condition() then - cleanup() - end - end) - ``` - ---- - -### `computed(callback, options?)` - -Call `computed` when you want to derive a new atom from one or more atoms. The callback will be memoized, meaning that subsequent calls to the atom return a cached value that is only re-calculated when the dependencies change. - -```luau -local todosAtom: Atom<{ string }> = atom({}) -local mapToUppercase = computed(function() - local result = table.clone(todosAtom()) - for key, todo in result do - result[key] = string.upper(todo) - end - return result -end) -``` - -Because `computed` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies. - -This function is also useful for optimizing `effect` calls that depend on multiple atoms. For instance, if an effect derives some value from two atoms, it will run twice if both atoms change at the same time. Using `computed` can group these dependencies together and avoid re-running side effects. - -#### Parameters - -- `callback`: A function that returns a new value depending on one or more atoms. - -- **optional** [`options`](#parameters): An object that configures the behavior of this atom. - -#### Returns - -`computed` returns a read-only atom. - ---- - -### `observe(callback, factory)` - -Call `observe` to create an instance of `factory` for each key present in a dictionary or array. Your factory can return a cleanup function to run when the key is removed or the observer is cleaned up. +Effects can be nested and they will clean up automatically when the parent effect re-runs or gets disposed. This means you don't have to manually clean up inner effects, and this applies to functions like `subscribe()` and `observe()`. > [!NOTE] -> Because `observe` tracks the lifetime of each key in your data, your keys must be unique and unchanging. If your data is not keyed by unique and stable identifiers, consider using [`mapped`](#mappedcallback-mapper) to transform it into a keyed object before passing it to `observe`. +> 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 `globals.trackInnerEffects` flag to assist with debugging. ```luau -local todosAtom: Atom<{ [string]: Todo }> = atom({}) +local getCounter, setCounter = signal(0) -local cleanup = observe(todosAtom, function(todo, key) - print(`Added {key}: {todo.name}`) +effect(function() + print(`Outer: {getCounter()}`) + effect(function() + print(`Inner: {getcounter()}`) + end) +end) + +setCounter(1) --> Outer: 1, Inner: 1 +setCounter(2) --> Outer: 2, Inner: 2 +``` + +--- + +### `untracked(callback)` + +In case you want to read signals that you don't want to subscribe to, you can use `untracked()` to essentially call a function _outside_ the current effect, preventing signals and effects in the callback from being tracked. + +```luau +local getCounter, setCounter = signal(0) +local getRuns, setRuns = signal(0) + +effect(function() + local runs = untracked(getRuns) + 1 + setRuns(runs) + print(`Count is {getCounter()}, ran {runs} times`) +end) --> Count is 0, ran 1 times + +setCounter(10) --> Count is 10, ran 2 times +setRuns(10) -- No output +setCounter(20) --> Count is 20, ran 11 times +``` + +Because `untracked()` executes the callback outside the current effect, nested effects created in the callback do not get tracked by the parent effect: + +```luau +local disposeInner +local disposeOuter = effect(function() + untracked(function() + disposeInner = effect(function() + return function() + print("Cleaned up inner effect") + end + end) + end) +end) + +disposeOuter() -- No output; outer effect did not track inner effect +disposeInner() --> Cleaned up inner effect +``` + +--- + +### `peek(callback)` + +Similar to `untracked()`, but does not prevent effects created by the callback from automatically disposing. If your callback reads a signal and creates effects, only the signals get untracked. + +```luau +local getCounter, setCounter = signal(0) +local disposeOuter = effect(function() + peek(function() + print(`Count is {getCounter()}`) + effect(function() + return function() + print("Cleaned up inner effect") + end + end) + end) +end) --> Count is 0 + +setCounter(1) -- No output; count was accessed in peek() +disposeOuter() --> Cleaned up inner effect +``` + +--- + +### `batched(callback)` + +Combines multiple signal updates made by the callback into a single commit that gets triggered when the callback completes. + +```luau +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") + +effect(function() + print(`Full name: {getName()} {getSurname()}`) +end) + +-- Combines both writes into a single update. +-- Once the callback completes, outputs "Full name: Foo Bar" +batched(function() + setName("Foo") + setSurname("Bar") +end) +``` + +--- + +### `effectScope(callback)` + +Scopes allow you to dispose multiple effects at once. The `effectScope` function creates an effect with no dependencies, and effects created inside the callback clean up when the effect scope disposes. + +```luau +local getCounter, setCounter = signal(0) + +local dispose = effectScope(function() + effect(function() + print(`Count 1 is {getCounter()}`) + end) + effect(function() + print(`Count 2 is {getCounter()}`) + end) +end) + +setCounter(1) --> Count 1 is 1, Count 2 is 1 +dispose() +setCounter(2) -- No output; effects got disposed +``` + +Similar to `effect()`, the callback can return a cleanup function that runs when the effect scope is disposed. + +--- + +### `listen(getter, callback)` + +The `listen` function creates an effect that only subscribes to the signals accessed by `getter`. Signals accessed by the callback will not be subscribed to, avoiding accidental subscriptions when you want to run side effects. + +The callback also receives the previous value, or `nil` when running for the first time. + +```luau +local getCounter, setCounter = signal(0) + +listen(getCounter, function(count, prevCount) + print(`Count is {count} (was {prevCount})`) +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. + +--- + +### `subscribe(getter, callback)` + +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) + +-- Does not output anything initially +subscribe(getCounter, function(count, prevCount) + print(`Count is {count} (was {prevCount})`) +end) + +setCounter(1) --> Count is 1 (was 0) +``` + +Note that nested effects can be created inside `subscribe`, and they will clean up automatically when the subscription re-runs or gets disposed. + +--- + +### `observe(getter, callback)` + +[Observers](https://sleitnick.github.io/RbxObservers/docs/observer-pattern) allow you to track the lifetime of a given state. The `observe` function executes the callback for every unique key added to a table, and disposes the callback when that key is removed. + +```luau +local getItems, setItems = signal({ a = 0, b = 0 }) + +observe(getItems, function(value, key) + print(`Added {key}`) return function() print(`Removed {key}`) end -end) +end) --> Added a, Added b + +setItems({ a = 0, c = 0 }) --> Removed b, Added c ``` -#### Parameters - -- `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value. - -- `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function. - -#### Returns - -`observe` returns a cleanup function. - ---- - -### `mapped(callback, mapper)` - -Call `mapped` to transform the keys and values of your state. The `mapper` function will be called for each key-value pair in the atom's state, and the new keys and atoms will be stored in a new atom. +The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed: ```luau -local todosAtom: Atom<{ Todo }> = atom({}) -local todosById = mapped(todosAtom, function(todo, index) - return todo, todo.id -end) -``` - -#### Parameters - -- `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms. - -- `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key: - - 1. Return a single value to map the table's original key to a new value. - 2. Return two values, the first being the value and the second being the key, to update both keys and values. - 3. Return `nil` for the value to remove the key from the resulting table. - -#### Returns - -`mapped` returns a read-only atom. - ---- - -### `peek(value, ...)` - -Call `peek` to call a function without tracking it as the dependency of an effect or a selector function. - -```luau -local nameAtom = atom("John") -local ageAtom = atom(25) - -effect(function() - local name = nameAtom() - local age = peek(ageAtom) -end) -``` - -#### Parameters - -- `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result. - -- **optional** `...args`: Additional arguments to pass to the value if it is a function. - -#### Returns - -`peek` returns the result of the given function. If the value is not a function, it will return the value as-is. - ---- - -### `batch(callback)` - -Call `batch` to defer state changes until after the callback has run. This is useful when you need to make multiple changes to the state and only want listeners to be notified once. - -```luau -local nameAtom = atom("John") -local ageAtom = atom(25) - -batch(function() - nameAtom("Jane") - ageAtom(26) -end) -``` - -#### Parameters - -- `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied. - -#### Returns - -`batch` does not return anything. - ---- - -## 📦 React - -### Setup - -Install the React bindings for Charm using your package manager of choice. - -```sh -npm install @rbxts/react-charm -yarn add @rbxts/react-charm -pnpm add @rbxts/react-charm -``` - -```toml -[dependencies] -ReactCharm = "littensy/react-charm@VERSION" -``` - ---- - -### `useAtom(callback, dependencies?)` - -Call `useAtom` at the top-level of a React component to read from an atom or selector. The component will re-render when the value changes. - -```luau -local todosAtom = require(script.Parent.todosAtom) - -local function Todos() - local todos = useAtom(todosAtom) - -- ... -end -``` - -If your selector depends on the component's state or props, remember to pass them in a dependency array. This prevents skipped updates when an untracked parameter of the selector changes. - -```luau -local todos = useAtom(function() - return searchTodos(props.filter) -end, { props.filter }) -``` - -#### Parameters - -- `callback`: An atom or selector function that depends on an atom. - -- **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state. - -#### Returns - -`useAtom` returns the current state of the atom. - ---- - -## 📦 Vide - -### Setup - -Install the Vide bindings for Charm using your package manager of choice. - -```sh -npm install @rbxts/vide-charm -yarn add @rbxts/vide-charm -pnpm add @rbxts/vide-charm -``` - -```toml -[dependencies] -VideCharm = "littensy/vide-charm@VERSION" -``` - ---- - -### `useAtom(callback)` - -Call `useAtom` in any scope to create a Vide source that returns the current state of an atom or selector. - -```luau -local todosAtom = require(script.Parent.todosAtom) - -local function Todos() - local todos = useAtom(todosAtom) - -- ... -end -``` - -#### Parameters - -- `callback`: An atom or selector function that depends on an atom. - -#### Returns - -`useAtom` returns a Vide source. - ---- - -## 📗 Charm Sync - -### Setup - -The Charm Sync package provides server-client synchronization for your Charm atoms. Install it using your package manager of choice. - -```sh -npm install @rbxts/charm-sync -yarn add @rbxts/charm-sync -pnpm add @rbxts/charm-sync -``` - -```toml -[dependencies] -CharmSync = "littensy/charm-sync@VERSION" -``` - ---- - -### `server(options)` - -Call `server` to create a server sync object. This synchronizes every client's atoms with the server's state by sending partial patches that the client merges into its state. - -```luau -local syncer = CharmSync.server({ - -- A dictionary of the atoms to sync, matching the client's - atoms = atomsToSync, - -- The minimum interval between state updates - interval = 0, - -- Whether to send a full history of changes made to the atoms (slower) - preserveHistory = false, - -- Whether to apply fixes for remote event limitations. Disable this option - -- when using a network library with custom ser/des, like ByteNet or Zap. - autoSerialize = true, -}) - --- Sends state updates to clients when a synced atom changes. --- Omitting sensitive information and data serialization can be done here. -syncer:connect(function(player, ...) - remotes.syncState:fire(player, ...) +local getItems, setItems = signal({ a = 0, b = 0 }) + +local dispose = observe(getItems, function(value, key) + local getValue = computed(function(prevValue) + return getItems()[key] or prevValue + end) + + effect(function() + local value = getValue() + print(`{key} = {value}`) + return function() + print(`Cleanup {key} = {value}`) + end + end) end) --- Sends the initial state to a player upon request. This should fire when a --- player joins the game. -remotes.requestState:connect(function(player) - syncer:hydrate(player) -end) +setItems({ a = 1, b = 0 }) --> Cleanup a = 0, a = 1 +setItems({ a = 1 }) --> Cleanup b = 0 +dispose() --> Cleanup a = 1 ``` -#### Parameters - -- `options`: An object to configure sync behavior. - - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client. - - - **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame. - - - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. - - - **optional** `autoSerialize`: Whether to apply validation and workarounds to certain [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#table-indexing). Defaults to `true`, but you should set it to `false` if you serialize remote arguments (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)). - -> [!NOTE] -> Charm sends table updates in the form of partial tables, so arrays will contain `nil` values, which has undefined behavior in remotes without serialization. -> -> Charm's default `autoSerialize` behavior fixes this, but it can interfere with custom serialization. Disable this option if you use a network library that serializes remote event arguments. - -#### Returns - -`server` returns an object with the following methods: - -- `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload. - -- `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms. - -#### Caveats - -- **Do not use values that cannot be sent over remotes** in your shared atoms. This includes functions, threads, and non-string keys in dictionaries. [Read more about argument limitations in remotes.](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) - -- **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. - -- **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `autoSerialize` accordingly! - --- -### `client(options)` +### `mapped(getter, mapper)` -Call `client` to create a client sync object. This synchronizes the client's atoms with the server's state by merging partial patches sent by the server into each atom. +The `mapped` function iterates over every key in a table and uses the mapper to generate a new key and value. The resulting table is returned in a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, the mapper is called for that key and its current value. + +The first value returned by the mapper is used as the new value: ```luau -local syncer = CharmSync.client({ - atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the server's - ignoreUnhydrated = true, -- Whether to ignore state updates before the initial update -}) +local getList, setList = signal({ "a", "b", "c" }) --- Applies state updates from the server to the client's atoms. --- Data deserialization can be done here. -remotes.syncState:connect(function(...) - syncer:sync(...) +local getUppercase = mapped(getList, function(value) + return string.upper(value) end) --- Requests the initial state from the server when the client joins the game. --- Before this runs, the client uses the atoms' default values. -remotes.requestState:fire() +print(getUppercase()) --> { "A", "B", "C" } ``` -#### Parameters +If the mapper returns two values, the second value is used as the new key: -- `options`: An object to configure sync behavior. +```luau +local getList, setList = signal({ "a", "b", "c" }) - - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server. +local getSwapped = mapped(getList, function(value, key) + return key, value +end) - - **optional** `ignoreUnhydrated`: Whether to ignore state updates before setting the initial state. Defaults to `true`. - -#### Returns - -`client` returns an object with the following methods: - -- `syncer:sync(...payloads)` applies a state update from the server. - -#### Caveats - -- **Charm does not handle network communication.** Use remote events or a network library to receive sync payloads. This includes requesting the initial state, which is implemented via `requestState` in the example above. +print(getSwapped()) --> { a = 1, b = 2, c = 3 } +``` --- -### `isNone(value)` +### `onCleanup(callback, failSilently?)` -Call `isNone` to check if a value is `None`. Charm's partial state patches omit values that did not change between sync events, so to mark keys for deletion, Charm uses the `None` marker. +The `onCleanup` function binds the callback to the currently running effect or effect scope. Multiple cleanup functions can be bound to the same effect. -This function can be used to check whether a value is about to be removed from an atom. +If there is no active effect, a warning is logged, unless `failSilently` is set to `true`. ```luau -local syncer = CharmSync.server({ atoms = atomsToSync }) - -syncer:connect(function(player, payload) - if - payload.type === "patch" - and payload.data.todosAtom - and CharmSync.isNone(payload.data.todosAtom.eggs) - then - -- 'eggs' will be removed from the client's todo list - end - remotes.syncState.fire(player, payload) +local dispose = effectScope(function() + onCleanup(function() + print("Cleaned up") + end) end) + +dispose() --> Cleaned up ``` -#### Parameters - -- `value`: Any value. If the value is `None`, `isNone` will return `true`. - -#### Returns - -`isNone` returns a boolean. - --- -## 🚀 Examples +### `atom(initialValue, equals?)` -### Counter atom +The `atom` function creates a new reactive signal and returns a single function that acts as both a getter and setter. + +If the atom is called with 0 arguments, the atom returns the current value and subscribes to the signal. Otherwise, when called with 1 or more arguments, the atom will update the signal's value. ```luau -local counterAtom = atom(0) +local counter = atom(0) --- Create a selector that returns double the counter value -local function doubleCounter() - return counterAtom() * 2 -end - --- Runs after counterAtom is updated and prints double the new value -subscribe(doubleCounter, function(value) - print(value) -end) - -counterAtom(1) --> 2 -counterAtom(function(count) +print(counter()) --> 0 +counter(1) +counter(function(count) return count + 1 -end) --> 4 +end) ``` -### React component +You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value: ```luau -local counter = require(script.Parent.counter) -local counterAtom = counter.counterAtom -local incrementCounter = counter.incrementCounter +local max = atom(0, function(incoming, current) + return incoming <= current +end) + +max(1) --> 1 +max(-1) --> 1 +``` + +--- + +## Examples + +Check out this simple [example project →](https://github.com/littensy/charm-example) + +### React Counter + +```luau +local counterStore = require("./stores/counterStore") local function Counter() - local count = useAtom(counterAtom) + local count = useSignalState(counterStore.getCounter) return React.createElement("TextButton", { - [React.Event.Activated] = incrementCounter, + [React.Event.Activated] = counterStore.incrementCounter, Text = `Count: {count}`, - Size = UDim2.new(0, 100, 0, 50), + Size = UDim2.fromOffset(100, 50), }) end ``` -### Vide component +### Vide Counter ```luau -local counter = require(script.Parent.counter) -local counterAtom = counter.counterAtom -local incrementCounter = counter.incrementCounter +local counterStore = require("./stores/counterStore") local function Counter() - local count = useAtom(counterAtom) + local count = useSignalState(counterStore.getCounter) return create "TextButton" { - Activated = incrementCounter, + Activated = counterStore.incrementCounter, Text = function() return `Count: {count()}` end, - Size = UDim2.new(0, 100, 0, 50), + Size = UDim2.fromOffset(100, 50), } end ``` -### Server-client sync - -Charm is designed for both client and server use, but there are often cases where the client needs to reference state that lives on the server. The CharmSync package provides a way to synchronize atoms between the server and its clients using remote events. - -Start by creating a set of atoms to sync between the server and clients. Export these atoms from a module to be shared between the server and client: - -```luau --- atoms.luau -local counter = require(script.Parent.counter) -local todos = require(script.Parent.todos) - -return { - counterAtom = counter.counterAtom, - todosAtom = todos.todosAtom, -} -``` - -Then, on the server, create a server sync object and pass in the atoms to sync. Use remote events to broadcast state updates and send initial state to clients upon request. - -> [!NOTE] -> If `preserveHistory` is `true`, the server will send multiple payloads to the client, so the callback passed to `connect` should accept a variadic `...payloads` parameter. Otherwise, you only need to handle a single `payload` parameter. - -```luau --- sync.server.luau -local atoms = require(script.Parent.atoms) - -local syncer = CharmSync.server({ atoms = atoms }) - --- Sends state updates to clients when a synced atom changes. --- Omitting sensitive information and data serialization can be done here. -syncer:connect(function(player, payload) - remotes.syncState.fire(player, payload) -end) - --- Sends the initial state to a player upon request. This should fire when a --- player joins the game. -remotes.requestState:connect(function(player) - syncer:hydrate(player) -end) -``` - -Finally, on the client, create a client sync object and use it to apply incoming state changes. - -```luau --- sync.client.luau -local atoms = require(script.Parent.atoms) - -local syncer = CharmSync.client({ atoms = atoms }) - --- Applies state updates from the server to the client's atoms. -remotes.syncState:connect(function(payload) - syncer:sync(payload) -end) - --- Requests the initial state from the server when the client joins the game. --- Before this runs, the client uses the atoms' default values. -remotes.requestState:fire() -``` - ---

diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index 70bdda9..85a70b0 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -141,7 +141,7 @@ export namespace client { */ export namespace server { /** - * Subscribes a client to the given atoms. When an update occurs, the client + * Subscribes a client to the given signals. When an update occurs, the client * will receive a partial state patch to merge into their local state. May be * called multiple times to subscribe to additional keys. * @@ -150,9 +150,9 @@ export namespace server { * registering signals on the client should be done through `signalToAtom()`. * * @param client The client receiving state updates. - * @param atoms A map of atoms to sync with the client. + * @param signals A map of signals to sync with the client. */ - export function subscribeClient(client: Player, atoms: { [key: string]: Atom }): void; + export function subscribeClient(client: Player, signals: { [key: string]: () => any }): void; /** * Unsubscribes a client from receiving all state updates. To only unsubscribe @@ -277,3 +277,14 @@ export function flattenAtoms(atoms: Atoms): Flatten * @returns `true` if the value is `None`, otherwise `false`. */ export function isNone(value: unknown): value is None; + +/** + * Converts a signal getter and setter into an atom. This is useful for + * syncing client signals with the server, as the sync system requires both + * the getter and setter to update state. + * + * @param getter A function that returns the current value of the signal. + * @param setter A function that sets the value of the signal. + * @returns An atom that can either get or set the signal's value. + */ +export function signalToAtom(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom; diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 4058bcf..528a4e2 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -9,7 +9,7 @@ local globals = Charm.globals type SyncStatus = number -type ClientAtoms = { +type ClientKeys = { [string]: SyncStatus, } @@ -29,17 +29,17 @@ type StateUpdate = { local PENDING_INITIAL_STATE: SyncStatus = 0 local LISTENING_FOR_CHANGES: SyncStatus = 1 -local trackedAtoms: { [string]: TrackedState } = {} +local trackedSignals: { [string]: TrackedState } = {} local globalUpdates: { StateUpdate } = {} -local clients: { [any]: ClientAtoms } = {} +local clients: { [any]: ClientKeys } = {} local connection: RBXScriptConnection? local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? local function startTracking(key: string, getter: () -> T) - if trackedAtoms[key] then + if trackedSignals[key] then -- Ignore duplicates, but warn against name collisions - if trackedAtoms[key].getter ~= getter then + if trackedSignals[key].getter ~= getter then warn("[Charm] Attempted to sync different states with the same key:", key) end return @@ -68,27 +68,27 @@ local function startTracking(key: string, getter: () -> T) trackedState.currentValue = newValue end) - trackedAtoms[key] = trackedState + trackedSignals[key] = trackedState end local function stopTracking(key: string) - local tracker = trackedAtoms[key] + local tracker = trackedSignals[key] if tracker then tracker.dispose() - trackedAtoms[key] = nil + trackedSignals[key] = nil end end local function purgeUntracked() local active: { [string]: boolean } = {} - for _, clientAtoms in clients do - for key in clientAtoms do + for _, clientKeys in clients do + for key in clientKeys do active[key] = true end end - for key in trackedAtoms do + for key in trackedSignals do if not active[key] then stopTracking(key) end @@ -107,9 +107,9 @@ local function diffGlobalUpdateBuffer(): { any } end local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload } - local clientAtoms = clients[client] + local clientKeys = clients[client] - if not clientAtoms then + if not clientKeys then return {} end @@ -125,7 +125,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { end local key = update.key - local status = clientAtoms[key] + local status = clientKeys[key] if status == LISTENING_FOR_CHANGES then table.insert(payloads, { @@ -137,20 +137,20 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { type = "init", data = { [key] = update.currentValue }, }) - clientAtoms[key] = LISTENING_FOR_CHANGES + clientKeys[key] = LISTENING_FOR_CHANGES end end - -- If a pending atom was never updated, we still need to send its initial - -- state. This can happen if the atom was registered after its last change. + -- If a pending signal was never updated, we still need to send its initial + -- state. This can happen if a signal was registered after its last change. local initData: { [string]: any } = {} - for key, status in clientAtoms do - local trackedState = trackedAtoms[key] + for key, status in clientKeys do + local trackedState = trackedSignals[key] if status == PENDING_INITIAL_STATE and trackedState then initData[key] = trackedState.currentValue - clientAtoms[key] = LISTENING_FOR_CHANGES + clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -165,7 +165,7 @@ local function diffGlobalState(): any local currentGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {} - for key, tracker in trackedAtoms do + for key, tracker in trackedSignals do currentGlobalState[key] = tracker.currentValue previousGlobalState[key] = tracker.previousValue tracker.previousValue = tracker.currentValue @@ -175,9 +175,9 @@ local function diffGlobalState(): any end local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload } - local clientAtoms = clients[client] + local clientKeys = clients[client] - if not clientAtoms then + if not clientKeys then return {} end @@ -185,16 +185,16 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo local initData: { [string]: any } = {} local patchData: { [string]: any } = {} - -- Create patches for initialized atoms that changed, and add atoms that - -- are still pending initial state to the init payload. - for key, status in clientAtoms do - local trackedState = trackedAtoms[key] + -- Create patches for initialized signals that changed, and add signals + -- that are still pending initial state to the init payload. + for key, status in clientKeys do + local trackedState = trackedSignals[key] if status == LISTENING_FOR_CHANGES then patchData[key] = globalPatch[key] elseif status == PENDING_INITIAL_STATE then initData[key] = trackedState.currentValue - clientAtoms[key] = LISTENING_FOR_CHANGES + clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -209,7 +209,7 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo return payloads end ---- Subscribes a client to the given atoms. When an update occurs, the client +--- Subscribes a client to the given signals. When an update occurs, the client --- will receive a partial state patch to merge into their local state. May be --- called multiple times to subscribe to additional keys. --- @@ -218,21 +218,21 @@ end --- registering signals on the client should be done through `signalToAtom()`. --- --- @param client The client receiving state updates. ---- @param atoms A map of atoms to sync with the client. -local function subscribeClient(client: any, atoms: { [string]: () -> any }) +--- @param signals A map of signals to sync with the client. +local function subscribeClient(client: any, signals: { [string]: () -> any }) if not clients[client] then clients[client] = {} end - local clientAtoms = clients[client] + local clientKeys = clients[client] - for key, state in atoms do + for key, state in signals do startTracking(key, if type(state) == "function" then state else state.get) - if not clientAtoms[key] then - -- Mark the atom as pending initial state so we send its full value - -- the next time we sync. - clientAtoms[key] = PENDING_INITIAL_STATE + if not clientKeys[key] then + -- Mark the signal as pending initial state so we send its full + -- value the next time we sync. + clientKeys[key] = PENDING_INITIAL_STATE end end end @@ -250,18 +250,18 @@ end --- @param client The client receiving state updates. --- @param keys The keys of the state to unsubscribe from. local function unsubscribeClientFrom(client: any, ...: string) - local clientAtoms = clients[client] + local clientKeys = clients[client] - if not clientAtoms then + if not clientKeys then return end for index = 1, select("#", ...) do local key = select(index, ...) - clientAtoms[key] = nil + clientKeys[key] = nil end - if next(clientAtoms) == nil then + if next(clientKeys) == nil then clients[client] = nil end end @@ -346,7 +346,7 @@ end --- Unsubscribes all clients, stops tracking all state, and disconnects --- from the automatic sync interval. local function reset() - table.clear(trackedAtoms) + table.clear(trackedSignals) table.clear(clients) table.clear(globalUpdates) disconnect() diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index 84d1d74..962d1dc 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -40,6 +40,13 @@ local function flattenState(states: NestedState): FlattenedState return result end +--- Converts a signal getter and setter into an atom. This is useful for +--- syncing client signals with the server, as the sync system requires both +--- the getter and setter to update state. +--- +--- @param getter A function that returns the current value of the signal. +--- @param setter A function that sets the value of the signal. +--- @return An atom that can either get or set the signal's value. local function signalToAtom(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom return function(...) return if select("#", ...) == 0 then getter() else setter((...)) diff --git a/packages/charm/src/__tests__/core/effect.spec.luau b/packages/charm/src/__tests__/core/effect.spec.luau index 0d729ff..b9db5ac 100644 --- a/packages/charm/src/__tests__/core/effect.spec.luau +++ b/packages/charm/src/__tests__/core/effect.spec.luau @@ -354,25 +354,4 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 0, "should not run after b update") end) -test("should allow self cleanup", function() - local calls = 0 - local cleanups = 0 - local a, setA = signal(0) - - effect(function(stopEffect) - onCleanup(function() - cleanups += 1 - end) - stopEffect() - a() - calls += 1 - end) - - assert(calls == 1, "should call effect once") - assert(cleanups == 1, "should call cleanup once") - setA(1) - assert(calls == 1, "should not call effect after a update") - assert(cleanups == 1, "should not call cleanup after a update") -end) - return {} diff --git a/packages/charm/src/__tests__/listen.spec.luau b/packages/charm/src/__tests__/listen.spec.luau index d8c14c9..f3098d1 100644 --- a/packages/charm/src/__tests__/listen.spec.luau +++ b/packages/charm/src/__tests__/listen.spec.luau @@ -4,7 +4,6 @@ local suite = require("../../../../test/suite") local signal = Charm.signal local listen = Charm.listen local effect = Charm.effect -local onCleanup = Charm.onCleanup local test = suite.test test("should listen to selector", function() @@ -116,24 +115,4 @@ test("should skip redundant updates", function() assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`) end) -test("should allow self cleanup", function() - local calls = 0 - local cleanups = 0 - local a, setA = signal(0) - - listen(a, function(_, _, stopEffect) - onCleanup(function() - cleanups += 1 - end) - stopEffect() - calls += 1 - end) - - assert(calls == 1, "should call effect once") - assert(cleanups == 1, "should call cleanup once") - setA(1) - assert(calls == 1, "should not call effect after a update") - assert(cleanups == 1, "should not call cleanup after a update") -end) - return {} diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index f60d2e0..3e6ca7a 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -51,9 +51,8 @@ type EffectScope = ReactiveNode & { } type Effect = ReactiveNode & { - fn: (stop: () -> ()) -> (() -> ())?, + fn: () -> (() -> ())?, cleanups: { () -> () }, - stop: () -> (), } type Computed = ReactiveNode & { @@ -117,7 +116,7 @@ local function run(e: Effect): () e.depsTail = nil e.flags = bit32.bor(WATCHING, RECURSED_CHECK) local prevSub = setActiveSub(e) - local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(e.fn, e.stop) + local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(e.fn) activeSub = prevSub e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) purgeDeps(e) @@ -197,7 +196,7 @@ local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) end local function computedOper(this: Computed): () -> T - return function(): T + local function computedGetter(): T local flags = this.flags if bit32.btest(flags, DIRTY) @@ -225,13 +224,14 @@ local function computedOper(this: Computed): () -> T end return this.value :: T end + return computedGetter end local function signalOper( this: Signal, equals: ((current: T, incoming: T) -> boolean)? ): (() -> T, (((T) -> T) | T) -> T) - local function setter(update: ((T) -> T) | T): T + local function signalSetter(update: ((T) -> T) | T): T local value: T if type(update) == "function" then local prevSub = setActiveSub(nil) @@ -262,7 +262,7 @@ local function signalOper( return this.currentValue end - local function getter(): T + local function signalGetter(): T if bit32.btest(this.flags, DIRTY) then if updateSignal(this) then local subs = this.subs @@ -285,7 +285,7 @@ local function signalOper( return this.currentValue end - return getter, setter + return signalGetter, signalSetter end local function effectScopeOper(this: EffectScope): () -> () @@ -311,7 +311,7 @@ end --- @param initialValue The initial value of the signal. --- @param equals A comparator function to determine if the signal's value has changed. --- @return A tuple containing the getter and setter functions for the signal. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#signalstate-equals +--- @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals local function signal( initialValue: T, equals: ((current: T, incoming: T) -> boolean)? @@ -326,30 +326,24 @@ local function signal( }, equals) end ---- Creates a reactive atom that acts as both a getter and setter. Calling the ---- atom with an argument sets its value, while calling it with no arguments ---- returns its current value. +--- Creates a reactive atom that acts as both a signal getter and setter. +--- Calling the atom with an argument sets its value, while calling it with no +--- arguments returns its current value. --- ---- @param value The initial value of the atom. If empty, the atom's value will be `undefined`. +--- @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. --- @param equals A comparator function to determine if the atom's value has changed. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-equals +--- @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T local getter, setter = signal(initialValue, equals) - return function(...) + local function atomGetterSetter(...) return if select("#", ...) == 0 then getter() else setter((...)) end + return atomGetterSetter end ---- Creates a new atom that is computed based on the values of other atoms. ---- The returned atom is read-only, and its value is automatically updated when ---- any atoms accessed from within the getter function change. ---- ---- @example ---- ```ts ---- const count = atom(1); ---- const plusOne = computed(() => count() + 1); ---- print(plusOne()); // 2 ---- ``` +--- Creates a new read-only signal that is computed based on the values of +--- other signals. The computed signal's value is automatically updated when a +--- change is detected in any of its dependencies. --- --- @param getter A function that produces the next value. --- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter @@ -360,50 +354,41 @@ local function computed(getter: (previousValue: T?) -> T): () -> T }) end ---- Creates an effect that runs a callback in response to atom state changes. ---- An effect tracks which atoms are accessed during its execution, and re-runs ---- the callback when those atoms change. +--- Creates an effect that runs a callback in response to signal state changes. +--- An effect tracks which signals are accessed during its execution, and runs +--- the callback when those signals change. --- --- The effect callback may return a cleanup function, which gets called once, --- either before the effect re-runs or when it is disposed. --- --- If the effect is called within another effect, it will be disposed when the ---- outer effect re-runs. +--- parent effect re-runs. --- --- @param fn The function to run when dependencies change. --- @return A function for disposing the effect. --- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback -local function effect(fn: (stop: () -> ()) -> ()): () -> () +local function effect(fn: () -> ()): () -> () local e: Effect = { fn = fn, flags = WATCHING, cleanups = {}, - stop = nil :: any, } - e.stop = effectOper(e) local prevSub = setActiveSub(e) if prevSub and (globals.trackInnerEffects or prevSub.flags == NONE) then link(e, prevSub, 0) end - local stopped = false - local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn, function() - stopped = true - end) + local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn) activeSub = prevSub if not success then error(result) elseif result then table.insert(e.cleanups, result) end - if stopped then - e.stop() - end - return e.stop + return effectOper(e) end ---- Creates an effect scope that can capture multiple reactive effects (i.e. ---- computed, effect, subscribe) created within it so that they can be disposed ---- together. +--- Creates an effect scope that can capture reactive effects and computed +--- signals created within it so that they can be disposed together. --- --- @param fn A function that may create reactive effects. --- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. @@ -428,12 +413,12 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> () return effectScopeOper(e) end ---- Registers a cleanup function to the current active effect or effect scope. ---- If there is no active effect or scope, a warning is logged and the callback ---- is not registered, unless `failSilently` is set to true. +--- Binds the cleanup function to the current active effect or effect scope. If +--- there is no active effect, a warning is logged, unless `failSilently` is +--- set to `true`. --- --- @param callback The cleanup function to register. ---- @param failSilently If true, suppresses the warning when there is no active effect or scope. Defaults to false. +--- @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. --- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently local function onCleanup(cleanup: () -> (), failSilently: boolean?) if activeSub and (activeSub :: Effect).cleanups then @@ -443,7 +428,7 @@ local function onCleanup(cleanup: () -> (), failSilently: boolean?) end end ---- Runs the callback without subscribing to atom updates or capturing inner +--- Runs the callback without subscribing to signal updates or capturing inner --- effects in the parent effect or scope. --- --- For an alternative that still cleans up inner effects, use `peek` instead. @@ -462,7 +447,7 @@ local function untracked(callback: (Args...) -> Results..., return unpack(results, 2) end ---- Runs the callback without subscribing to atom updates, but still captures +--- Runs the callback without subscribing to signal updates, but still captures --- inner effects so that they dispose with the parent effect. --- --- To avoid capturing inner effects, use `untracked` instead. @@ -489,10 +474,10 @@ local function peek(callback: (Args...) -> Results..., ...: end end ---- Combines multiple atom updates into a single "commit" that runs effects at ---- the end of the provided callback. +--- Combines multiple updates into a single "commit" that runs effects and +--- computed signals after the provided callback finishes running. --- ---- @param callback A function that performs multiple atom updates. +--- @param callback A function that performs multiple updates. --- @param args Arguments to pass to the callback. --- @return The return value of the callback. --- @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback @@ -506,18 +491,56 @@ local function batched(callback: (Args...) -> Results..., . return unpack(results, 2) end +--- Converts a non-signal into a computed signal. If the callback is already a +--- signal, it is returned as-is. Useful for memoizing a getter function +--- without creating unnecessary nested signals. +--- +--- @param getter A function that returns the value to track. +--- @return A computed signal that tracks the value returned by the getter. +local function toComputed(getter: () -> T): () -> T + local name = debug.info(getter, "n") + if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then + return getter + end + return computed(function() + return getter() + end) +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. +--- +--- Effects created within the callback are cleaned up when the listener runs +--- or the listener is disposed. +--- +--- @param getter A function that returns the value to watch. +--- @param callback The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback +local function listen(getter: () -> T, callback: (state: T, previous: T?) -> ()): () -> () + local tracked = toComputed(getter) + local current: T + return effect(function() + local previous = current + current = tracked() + peek(callback, current, previous) + end) +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. --- +--- Effects created within the callback are cleaned up when the listener runs +--- or the listener is disposed. +--- --- @param getter A function that returns the value to watch. --- @param callback The function to run when the value changes. --- @return A function for disposing the effect. --- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback local function subscribe(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> () - local tracked = computed(function() - return getter() - end) + local tracked = toComputed(getter) local current: T local mounted = true return effect(function() @@ -531,34 +554,6 @@ local function subscribe(getter: () -> T, callback: (state: T, lastState: T) end) 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, the previous value, and a cleanup function as arguments. ---- ---- @param getter A function that returns the value to watch. ---- @param callback The function to run when the value changes. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback -local function listen(getter: () -> T, callback: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () - local tracked = computed(function() - return getter() - end) - local current: T - local disposed = false - local dispose = function() - disposed = true - end - dispose = effect(function() - local previous = current - current = tracked() - peek(callback, current, previous, dispose) - end) - if disposed then - dispose() - end - return dispose -end - local reactiveSystem = createReactiveSystem({ update = function(node) if node.depsTail then diff --git a/packages/charm/src/globals.luau b/packages/charm/src/globals.luau index d1c1b17..af6a381 100644 --- a/packages/charm/src/globals.luau +++ b/packages/charm/src/globals.luau @@ -6,11 +6,11 @@ local isO2 = inlineTest() ~= "inlineTest" local isDev = not isO2 or _G.__DEV__ == true return { - --- Enforces synchronous, non-yielding behavior in atoms and effects. + --- Enforces synchronous, non-yielding behavior in signals and effects. --- Also enables state validation in Charm Sync to catch sync errors early. --- Enabled if the optimization level is below 2, which is true in studio. strict = isDev, - --- Enforces immutability of atoms by deep-freezing table values. Enabled + --- Enforces immutability of tables by deep-freezing table values. Enabled --- if the optimization level is below 2, which is true in studio. frozen = isDev, --- Whether parent effects should track inner effects and clean them up diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index ace5fac..43d56d8 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -20,13 +20,13 @@ export type Equals = (current: T, incoming: T) => boolean; */ export const globals: { /** - * Enforces synchronous, non-yielding behavior in atoms and effects. + * Enforces synchronous, non-yielding behavior in signals and effects. * Also enables state validation in Charm Sync to catch sync errors early. * Enabled if the optimization level is below 2, which is true in studio. */ strict: boolean; /** - * Enforces immutability of atoms by deep-freezing table values. Enabled + * Enforces immutability of tables by deep-freezing table values. Enabled * if the optimization level is below 2, which is true in studio. */ frozen: boolean; @@ -44,84 +44,138 @@ export const globals: { * * @param initialValue The initial value of the signal. * @param equals A comparator function to determine if the signal's value has changed. - * @return A tuple containing the getter and setter functions for the signal. - * @see https://github.com/littensy/charm?tab=readme-ov-file#signalstate-equals + * @returns A tuple containing the getter and setter functions for the signal. + * @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals */ export function signal( - value: T, + initialValue: T, equals?: Equals, -): LuaTuple<[() => T, (newValue: T | ((currentValue: T) => T)) => T]>; +): LuaTuple<[getter: () => T, setter: (newValue: T | ((currentValue: T) => T)) => T]>; export function signal(): LuaTuple< - [() => T | undefined, (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined] + [getter: () => T | undefined, setter: (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined] >; /** - * Creates a reactive atom that acts as both a getter and setter. Calling the - * atom with an argument sets its value, while calling it with no arguments - * returns its current value. + * Creates a reactive atom that acts as both a signal getter and setter. + * Calling the atom with an argument sets its value, while calling it with no + * arguments returns its current value. * - * @param value The initial value of the atom. If empty, the atom's value will be `undefined`. + * @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. * @param equals A comparator function to determine if the atom's value has changed. - * @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-options + * @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals */ -export function atom(value: T, equals?: Equals): Atom; +export function atom(initialValue: T, equals?: Equals): Atom; export function atom(): Atom; /** - * Creates a new atom that is computed based on the values of other atoms. - * The returned atom is read-only, and its value is automatically updated when - * any atoms accessed from within the getter function change. - * - * If an initial value is provided, the getter will receive this value as its - * first argument on the initial run, instead of `undefined`. - * - * @example - * ```ts - * const count = atom(1); - * const plusOne = computed(() => count() + 1); - * print(plusOne()); // 2 - * ``` + * Creates a new read-only signal that is computed based on the values of + * other signals. The computed signal's value is automatically updated when a + * change is detected in any of its dependencies. * * @param getter A function that produces the next value. - * @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options + * @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter */ export function computed(getter: (previousValue?: T) => T): () => T; /** - * Creates an effect that runs a callback in response to atom state changes. - * An effect tracks which atoms are accessed during its execution, and re-runs - * the callback when those atoms change. + * Creates an effect that runs a callback in response to signal state changes. + * An effect tracks which signals are accessed during its execution, and runs + * the callback when those signals change. * * The effect callback may return a cleanup function, which gets called once, * either before the effect re-runs or when it is disposed. * * If the effect is called within another effect, it will be disposed when the - * outer effect re-runs. + * parent effect re-runs. * - * @param callback The function to run when dependencies change. + * @param fn The function to run when dependencies change. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback */ -export function effect(callback: (dispose: Cleanup) => Cleanup | void): Cleanup; +export function effect(callback: () => Cleanup | void): Cleanup; /** - * Creates an effect scope that can capture multiple reactive effects (i.e. - * computed, effect, subscribe) created within it so that they can be disposed - * together. + * Creates an effect scope that can capture reactive effects and computed + * signals created within it so that they can be disposed together. * - * @param callback A function that may create reactive effects. - * @param detached If true, the scope will not be automatically disposed of - * when the parent scope is disposed. Defaults to false. + * @param fn A function that may create reactive effects. + * @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. * @returns A function for disposing all effects created within the scope. * @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached */ export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup; +/** + * Binds the cleanup function to the current active effect or effect scope. If + * there is no active effect, a warning is logged, unless `failSilently` is + * set to `true`. + * + * @param callback The cleanup function to register. + * @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. + * @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently + */ +export function onCleanup(callback: Cleanup, failSilently?: boolean): void; + +/** + * Runs the callback without subscribing to signal updates or capturing inner + * effects in the parent effect or scope. + * + * For an alternative that still cleans up inner effects, use `peek` instead. + * + * @param callback The callback function. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + * @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback + */ +export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; + +/** + * Runs the callback without subscribing to signal updates, but still captures + * inner effects so that they dispose with the parent effect. + * + * To avoid capturing inner effects, use `untracked` instead. + * + * @param callback The callback function. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + * @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback + */ +export function peek(callback: (...args: Args) => Result, ...args: Args): Result; + +/** + * Combines multiple updates into a single "commit" that runs effects and + * computed signals after the provided callback finishes running. + * + * @param callback A function that performs multiple updates. + * @param args Arguments to pass to the callback. + * @returns The return value of the callback. + * @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback + */ +export function batched(callback: (...args: Args) => Result, ...args: Args): Result; + +/** + * 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. + * + * Effects created within the callback are cleaned up when the listener runs + * or the listener is disposed. + * + * @param getter A function that returns the value to watch. + * @param callback The function to run when the value changes. + * @returns A function for disposing the effect. + * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback + */ +export function listen(getter: () => T, callback: (value: T, previousValue: T | undefined) => void): Cleanup; + /** * 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. + * * @param getter A function that returns the value to watch. * @param callback The function to run when the value changes. * @returns A function for disposing the effect. @@ -130,28 +184,12 @@ export function effectScope(callback: () => Cleanup | void, detached?: boolean): export function subscribe(getter: () => T, callback: (value: T, previousValue: T) => void): Cleanup; /** - * 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, the previous value, and a cleanup function as arguments. + * Creates a new read-only signal that is computed by mapping over the entries + * of an existing atom. If the key is omitted from the mapper's return value, + * the original key is preserved. * - * @param getter A function that returns the value to watch. - * @param callback The function to run when the value changes. - * @returns A function for disposing the effect. - * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback - */ -export function listen( - getter: () => T, - callback: (value: T, previousValue: T | undefined, dispose: Cleanup) => void, -): Cleanup; - -/** - * Creates a new atom that is computed by mapping over the entries of an - * existing atom. The returned atom is read-only, and its value is - * automatically updated when the source atom changes. - * - * @param getter A function that returns the table to map over. - * @param mapper A function that transforms each value-key pair from the source - * into a new value or value-key pair for the resulting table. + * @param getter Returns the table to map over. + * @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper */ export function mapped( @@ -170,70 +208,27 @@ export function mapped( ): () => { readonly [K in KO]: VO }; /** - * Calls the observer function for each unique key in the table returned by the - * getter. The observer is called once for each key, and again when a new key + * 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 * is added. * - * The observer may return a cleanup function, which will be called when the - * key is removed or when the entire observer is disposed. + * The callback may return a cleanup function, which will be called when the + * key is removed or when the entire callback is disposed. + * + * Effects created within the callback are cleaned up when the item is removed + * from the table or when the callback is disposed. * * @param getter A function that returns the table to observe. - * @param observer A function that is called for each key in the table. + * @param callback A function that is called for each key in the table. * @returns A function for disposing the observer. - * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-observer + * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback */ export function observe( getter: () => Map | ReadonlyMap | readonly V[], - observer: (value: V, key: K) => Cleanup | void, + callback: (value: V, key: K) => Cleanup | void, ): Cleanup; // Overload for objects export function observe( getter: () => { readonly [K in Key]: V }, - observer: (value: V, key: K) => Cleanup | void, + callback: (value: V, key: K) => Cleanup | void, ): Cleanup; - -/** - * Combines multiple atom updates into a single "commit" that runs effects at - * the end of the provided callback. - * - * @param callback A function that performs multiple atom updates. - * @param args Arguments to pass to the callback. - * @returns The return value of the callback. - */ -export function batched(callback: (...args: Args) => Result, ...args: Args): Result; - -/** - * Runs the callback without subscribing to atom updates or capturing inner - * effects in the parent effect or scope. - * - * For an alternative that still cleans up inner effects, use `peek` instead. - * - * @param callback The callback function. - * @param args Arguments to pass to the callback. - * @returns The return value of the callback. - */ -export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; - -/** - * Runs the callback without subscribing to atom updates, but still captures - * inner effects so that they dispose with the parent effect. - * - * To avoid capturing inner effects, use `untracked` instead. - * - * @param callback The callback function. - * @param args Arguments to pass to the callback. - * @returns The return value of the callback. - */ -export function peek(callback: (...args: Args) => Result, ...args: Args): Result; - -/** - * Registers a cleanup function to the current active effect or effect scope. - * If there is no active effect or scope, a warning is logged and the callback - * is not registered, unless `failSilently` is set to true. - * - * @param callback The cleanup function to register. - * @param failSilently If true, suppresses the warning when there is no active - * effect or scope. Defaults to false. - * @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently - */ -export function onCleanup(callback: Cleanup, failSilently?: boolean): void; diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau index 8f9b528..940a6ae 100644 --- a/packages/charm/src/mapped.luau +++ b/packages/charm/src/mapped.luau @@ -8,7 +8,7 @@ type Mapped = & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } - local currentData: { [KI]: VI } = {} + local currentInput: { [KI]: VI } = {} local keyCache: { [KI]: KO } = {} local function map(value: VI, key: KI): (VO, KO) @@ -25,38 +25,38 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: return computed(function(previousOutput) local currentOutput: { [KO]: VO } = previousOutput or {} - local previousData = currentData - currentData = getter() + local previousInput = currentInput + currentInput = getter() - for key in previousData do - if currentData[key] ~= nil then + for keyIn in previousInput do + if currentInput[keyIn] ~= nil then continue end - local cachedKey = keyCache[key] + local prevKeyOut = keyCache[keyIn] - if cachedKey then + if prevKeyOut then if currentOutput == previousOutput then currentOutput = table.clone(currentOutput) end - currentOutput[cachedKey] = nil - keyCache[key] = nil + currentOutput[prevKeyOut] = nil + keyCache[keyIn] = nil end end - for key, value in currentData do - if value == previousData[key] then + for keyIn, valueIn in currentInput do + if valueIn == previousInput[keyIn] then continue end - local valueOut, keyOut = map(value, key) + local valueOut, keyOut = map(valueIn, keyIn) if currentOutput[keyOut] ~= valueOut then if currentOutput == previousOutput then currentOutput = table.clone(currentOutput) end currentOutput[keyOut] = valueOut - keyCache[key] = keyOut + keyCache[keyIn] = keyOut end end @@ -64,12 +64,12 @@ local function mapped(getter: () -> { [KI]: VI }, mapper: (item: end) end ---- Creates a new atom that is computed by mapping over the entries of an ---- existing atom. The returned atom is read-only, and its value is ---- automatically updated when the source atom changes. +--- Creates a new read-only signal that is computed by mapping over the entries +--- of an existing atom. If the key is omitted from the mapper's return value, +--- the original key is preserved. --- ---- @param getter A function that returns the table to map over. ---- @param mapper A function that transforms each value-key pair from the source into a new value or value-key pair for the resulting table. +--- @param getter Returns the table to map over. +--- @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. --- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper local mapped = mapped :: Mapped diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau index 5f5f9ba..bc8230e 100644 --- a/packages/charm/src/observe.luau +++ b/packages/charm/src/observe.luau @@ -6,18 +6,21 @@ local effect = core.effect local effectScope = core.effectScope local onCleanup = core.onCleanup ---- Calls the observer function for each unique key in the table returned by the ---- getter. The observer is called once for each key, and again when a new key +--- 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 --- is added. --- ---- The observer may return a cleanup function, which will be called when the ---- key is removed or when the entire observer is disposed. +--- The callback may return a cleanup function, which will be called when the +--- key is removed or when the entire callback is disposed. +--- +--- Effects created within the callback are cleaned up when the item is removed +--- from the table or when the callback is disposed. --- --- @param getter A function that returns the table to observe. ---- @param observer A function that is called for each key in the table. +--- @param callback A function that is called for each key in the table. --- @return A function for disposing the observer. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-observer -local function observe(getter: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () +--- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback +local function observe(getter: () -> { [K]: V }, callback: (item: V, key: K) -> ...(() -> ())?): () -> () local scopes: { [K]: () -> () } = {} local currentItems: { [K]: V } = {} @@ -33,7 +36,7 @@ local function observe(getter: () -> { [K]: V }, observer: (item: V, key: for key, value in currentItems do if previousItems[key] == nil and scopes[key] == nil then scopes[key] = effectScope(function() - return observer(value, key) + return callback(value, key) end, true) end end diff --git a/packages/react-charm/src/__tests__/spec.luau b/packages/react-charm/src/__tests__/spec.luau index 2dcdfe7..69f078a 100644 --- a/packages/react-charm/src/__tests__/spec.luau +++ b/packages/react-charm/src/__tests__/spec.luau @@ -1,11 +1,11 @@ local suite = require("../../../../test/suite") -suite.describe("useAtom", function() - require("./useAtom.spec") +suite.describe("useSignalState", function() + require("./useSignalState.spec") end) -suite.describe("useAtomBinding", function() - require("./useAtomBinding.spec") +suite.describe("useSignalBinding", function() + require("./useSignalBinding.spec") end) return {} diff --git a/packages/react-charm/src/__tests__/useAtomBinding.spec.luau b/packages/react-charm/src/__tests__/useSignalBinding.spec.luau similarity index 67% rename from packages/react-charm/src/__tests__/useAtomBinding.spec.luau rename to packages/react-charm/src/__tests__/useSignalBinding.spec.luau index d1fa23d..1ad6df3 100644 --- a/packages/react-charm/src/__tests__/useAtomBinding.spec.luau +++ b/packages/react-charm/src/__tests__/useSignalBinding.spec.luau @@ -3,45 +3,43 @@ local ReactCharm = require("../") local renderHook = require("./helpers/renderHook") local suite = require("../../../../test/suite") -local atom = Charm.atom -local useAtomBinding = ReactCharm.useAtomBinding local test = suite.test -test("tracks atom state", function() - local state = atom(0) +test("tracks signal state", function() + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtomBinding(state) + return ReactCharm.useSignalBinding(getState) end) assert(hook.result:getValue() == 0, "should return 0") - state(1) + setState(1) assert(hook.result:getValue() == 1, "should return 1") end) test("accepts a selector", function() - local state = atom(0) + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtomBinding(function() - return 2 * state() + return ReactCharm.useSignalBinding(function() + return 2 * getState() end) end) assert(hook.result:getValue() == 0, "should return 0") - state(1) + setState(1) assert(hook.result:getValue() == 2, "should return 2") end) test("tracks dependencies", function() - local state = atom(0) + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtomBinding(state, { state }) + return ReactCharm.useSignalBinding(getState, { getState }) end) assert(hook.result:getValue() == 0, "should return 0") - state(1) + setState(1) assert(hook.result:getValue() == 1, "should return 1") - state = atom(0) + getState, setState = Charm.signal(0) hook.rerender() assert(hook.result:getValue() == 0, "should return 0") - state(1) + setState(1) assert(hook.result:getValue() == 1, "should return 1") end) diff --git a/packages/react-charm/src/__tests__/useAtom.spec.luau b/packages/react-charm/src/__tests__/useSignalState.spec.luau similarity index 65% rename from packages/react-charm/src/__tests__/useAtom.spec.luau rename to packages/react-charm/src/__tests__/useSignalState.spec.luau index c695f9f..ca22e49 100644 --- a/packages/react-charm/src/__tests__/useAtom.spec.luau +++ b/packages/react-charm/src/__tests__/useSignalState.spec.luau @@ -3,45 +3,43 @@ local ReactCharm = require("../") local renderHook = require("./helpers/renderHook") local suite = require("../../../../test/suite") -local atom = Charm.atom -local useAtom = ReactCharm.useAtom local test = suite.test -test("tracks atom state", function() - local state = atom(0) +test("tracks signal state", function() + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtom(state) + return ReactCharm.useSignalState(getState) end) assert(hook.result == 0, "should return 0") - state(1) + setState(1) assert(hook.result == 1, "should return 1") end) test("accepts a selector", function() - local state = atom(0) + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtom(function() - return 2 * state() + return ReactCharm.useSignalState(function() + return 2 * getState() end) end) assert(hook.result == 0, "should return 0") - state(1) + setState(1) assert(hook.result == 2, "should return 2") end) test("tracks dependencies", function() - local state = atom(0) + local getState, setState = Charm.signal(0) local hook = renderHook(function() - return useAtom(state, { state }) + return ReactCharm.useSignalState(getState, { getState }) end) assert(hook.result == 0, "should return 0") - state(1) + setState(1) assert(hook.result == 1, "should return 1") - state = atom(0) + getState, setState = Charm.signal(0) hook.rerender() assert(hook.result == 0, "should return 0") - state(1) + setState(1) assert(hook.result == 1, "should return 1") end) diff --git a/packages/react-charm/src/index.d.ts b/packages/react-charm/src/index.d.ts index f24d4af..db8ac24 100644 --- a/packages/react-charm/src/index.d.ts +++ b/packages/react-charm/src/index.d.ts @@ -1,29 +1,31 @@ import { Binding } from "@rbxts/react"; /** - * A hook that subscribes to changes in the given atom or selector. The - * component is re-rendered with the new state after an update. + * Returns a React state that updates when the getter function updates. + * Re-renders the component when the value changes. * * If the `dependencies` array is provided, a new effect is created for the - * current `callback` when the dependencies change. Otherwise, the effect is + * current `getter` when the dependencies change. Otherwise, the effect is * created only once, when the component is mounted. * - * @param callback The atom or selector to watch. - * @param dependencies An array of values used to memoize the callback. - * @returns The current state. + * @param getter The signal or selector to watch. + * @param dependencies An array of values used to memoize the getter. + * @return The current state. */ -export function useAtom(callback: () => T, dependencies?: any[]): T; +export function useSignalState(getter: () => T, dependencies?: any[]): T; /** - * A hook that subscribes to changes in the given atom or selector. Returns - * a binding that updates when the state changes. + * Returns a React binding that updates when the getter function updates. * * If the `dependencies` array is provided, a new effect is created for the - * current `callback` when the dependencies change. Otherwise, the effect is + * current `getter` when the dependencies change. Otherwise, the effect is * created only once, when the component is mounted. * - * @param callback The atom or selector to watch. - * @param dependencies An array of values used to memoize the callback. - * @returns A binding that stores the current state. + * @param getter The atom or selector to watch. + * @param dependencies An array of values used to memoize the getter. + * @return A binding that stores the current state. */ -export function useAtomBinding(callback: () => T, dependencies?: any[]): Binding; +export function useSignalBinding(getter: () => T, dependencies?: any[]): Binding; + +// Aliases +export { useSignalState as useAtom }; diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index a923ff5..481275a 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -1,31 +1,30 @@ local Charm = require("./Charm") local React = require("./React") ---- A hook that subscribes to changes in the given atom or selector. The ---- component is re-rendered with the new state after an update. +--- Returns a React state that updates when the getter function updates. +--- Re-renders the component when the value changes. --- --- If the `dependencies` array is provided, a new effect is created for the --- current `getter` when the dependencies change. Otherwise, the effect is --- created only once, when the component is mounted. --- ---- @param getter The atom or selector to watch. +--- @param getter The signal or selector to watch. --- @param dependencies An array of values used to memoize the getter. --- @return The current state. -local function useAtom(getter: () -> T, dependencies: { any }?): T - local state, setState = React.useState(getter) +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(setState, getter()) + Charm.untracked(setValue, getter()) end) end, dependencies or {}) - return state + return value end ---- A hook that subscribes to changes in the given atom or selector. Returns ---- a binding that updates when the state changes. +--- Returns a React binding that updates when the getter function updates. --- --- If the `dependencies` array is provided, a new effect is created for the --- current `getter` when the dependencies change. Otherwise, the effect is @@ -34,9 +33,9 @@ end --- @param getter The atom or selector to watch. --- @param dependencies An array of values used to memoize the getter. --- @return A binding that stores the current state. -local function useAtomBinding(getter: () -> T, dependencies: { any }?) - local initialState = React.useMemo(getter, {}) - local binding, setBinding = React.useBinding(initialState) +local function useSignalBinding(getter: () -> T, dependencies: { any }?) + local initialValue = React.useMemo(getter, {}) + local binding, setBinding = React.useBinding(initialValue) React.useLayoutEffect(function() return Charm.effect(function() @@ -49,6 +48,7 @@ local function useAtomBinding(getter: () -> T, dependencies: { any }?) end return { - useAtom = useAtom, - useAtomBinding = useAtomBinding, + useSignalState = useSignalState, + useSignalBinding = useSignalBinding, + useAtom = useSignalState, } diff --git a/packages/vide-charm/src/__tests__/spec.luau b/packages/vide-charm/src/__tests__/spec.luau index fddcda9..79a4998 100644 --- a/packages/vide-charm/src/__tests__/spec.luau +++ b/packages/vide-charm/src/__tests__/spec.luau @@ -1,7 +1,7 @@ local suite = require("../../../../test/suite") -suite.describe("useAtom", function() - require("./useAtom.spec") +suite.describe("useSignalState", function() + require("./useSignalState.spec") end) return {} diff --git a/packages/vide-charm/src/__tests__/useAtom.spec.luau b/packages/vide-charm/src/__tests__/useSignalState.spec.luau similarity index 59% rename from packages/vide-charm/src/__tests__/useAtom.spec.luau rename to packages/vide-charm/src/__tests__/useSignalState.spec.luau index 88c6b5b..6d13460 100644 --- a/packages/vide-charm/src/__tests__/useAtom.spec.luau +++ b/packages/vide-charm/src/__tests__/useSignalState.spec.luau @@ -5,55 +5,55 @@ local suite = require("../../../../test/suite") local test = suite.test -test("tracks atom state", function() - local state = Charm.atom(0) +test("tracks signal state", function() + local getState, setState = Charm.signal(0) local source local cleanup = Vide.root(function() - source = VideCharm.useAtom(state) + source = VideCharm.useSignalState(getState) end) assert(source() == 0, "should return 0") - state(1) + setState(1) assert(source() == 1, "should return 1") cleanup() end) test("accepts a selector", function() - local state = Charm.atom(0) + local getState, setState = Charm.signal(0) local source local cleanup = Vide.root(function() - source = VideCharm.useAtom(function() - return 2 * state() + source = VideCharm.useSignalState(function() + return 2 * getState() end) end) assert(source() == 0, "should return 0") - state(1) + setState(1) assert(source() == 2, "should return 2") cleanup() end) test("updates source in untracked", function() - local outerAtom = Charm.atom(nil :: any) - local innerAtom = Charm.atom(0) + local getOuter, setOuter = Charm.signal(nil :: any) + local getInner, setInner = Charm.signal(0) local spyCalls = 0 local cleanup = Vide.root(function() - return Vide.show(VideCharm.useAtom(outerAtom), function() - -- Charm effects created in Vide scopes triggered by useAtom might - -- incorrectly get treated as an inner effect in useAtom. This test + return Vide.show(VideCharm.useSignalState(getOuter), function() + -- Charm effects created in Vide scopes triggered by useSignalState might + -- incorrectly get treated as an inner effect in useSignalState. This test -- ensures that doesn't happen. Vide.cleanup(Charm.effect(function() - innerAtom() + getInner() spyCalls += 1 end)) return nil end) end) assert(spyCalls == 0, "should not call spy initially") - outerAtom(true) + setOuter(true) assert(spyCalls == 1, "should call spy when visible") - innerAtom(1) + setInner(1) assert(spyCalls == 2, "spy should run when counter updates") - outerAtom({}) -- set to a truthy value to trigger an outer effect re-run - innerAtom(2) + setOuter({}) -- set to a truthy value to trigger an outer effect re-run + setInner(2) assert(spyCalls == 3, "spy should still run when counter updates") cleanup() end) diff --git a/packages/vide-charm/src/index.d.ts b/packages/vide-charm/src/index.d.ts index f9541ab..eb12134 100644 --- a/packages/vide-charm/src/index.d.ts +++ b/packages/vide-charm/src/index.d.ts @@ -1,8 +1,11 @@ /** - * Subscribes to the state of an atom or selector. Returns a source that - * updates when the state changes. + * Returns a reactive source that subscribes to the current value of the given + * signal or getter function. * - * @param callback The atom or selector to subscribe to. - * @returns The reactive source. + * @param getter The getter function to subscribe to. + * @return The reactive source. */ -export function useAtom(callback: () => T, dependencies?: any[]): () => T; +export function useSignalState(getter: () => T, dependencies?: any[]): () => T; + +// Aliases +export { useSignalState as useAtom }; diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index d6d27ae..34b1c7b 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -1,12 +1,12 @@ local Charm = require("./Charm") local Vide = require("./Vide") ---- Returns a reactive source that updates when the given getter function's ---- value changes. +--- Returns a reactive source that subscribes to the current value of the given +--- signal or getter function. --- ---- @param getter The atom or selector to subscribe to. +--- @param getter The getter function to subscribe to. --- @return The reactive source. -local function useAtom(getter: () -> State): () -> State +local function useSignalState(getter: () -> State): () -> State local state = Vide.source(getter()) Vide.cleanup(Charm.effect(function() @@ -18,5 +18,6 @@ local function useAtom(getter: () -> State): () -> State end return { - useAtom = useAtom, + useSignalState = useSignalState, + useAtom = useSignalState, }