refactor: reorganize repo, create deep-charm

This commit is contained in:
littensy 2026-02-24 19:49:34 -08:00
parent d741640d28
commit 89478f679c
64 changed files with 913 additions and 744 deletions

View file

@ -21,7 +21,7 @@ jobs:
run: lute run check
- name: Run tests
run: lute run test
run: lute run tests
- name: Build models
run: lute run build

2
.gitignore vendored
View file

@ -2,7 +2,7 @@
/.pesde
/build
/packages/**/wally.lock
/packages/**/LICENSE.md
/packages/**/LICENSE*
/packages/**/README.md
/sourcemap.json
node_modules

View file

@ -1 +0,0 @@
(require)("../tests/run.server")

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright 2024-present Littensy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,7 +0,0 @@
Copyright 2024 Littensy
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

141
README.md
View file

@ -14,7 +14,7 @@
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/littensy/charm/ci.yml?style=for-the-badge&branch=main&logo=github)
[![NPM Version](https://img.shields.io/npm/v/@rbxts/charm.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@rbxts/charm)
[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md)
[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE)
</div>
@ -54,7 +54,6 @@ Charm is a state management library that brings the [fine-grained reactivity](ht
- [`batch(callback)`](#batchcallback)
- [`mapped(getter, mapper)`](#mappedgetter-mapper)
- [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently)
- [`reactive(initialValue)`](#reactiveinitialvalue)
- [`atom(initialValue, equals?)`](#atominitialvalue-equals)
- [`trigger(callback)`](#triggercallback)
- [`flags`](#flags)
@ -64,6 +63,12 @@ Charm is a state management library that brings the [fine-grained reactivity](ht
- [Server API](#config)
- [Client API](#clientaddsignalssetters)
- [Sync Caveats](#sync-caveats)
- [Deep Reactivity](#deep-reactivity)
- [Installation](#installation-2)
- [`reactive(initialValue)`](#reactiveinitialvalue)
- [Mutation vs. update function](#mutation-vs-update-function)
- [`toRaw(value)`](#torawvalue)
- [`isReactive(value)`](#isreactivevalue)
- [Migration](#migration)
- [Examples](#examples)
@ -539,45 +544,6 @@ dispose() -- Cleaned up
---
### `reactive(initialValue)`
By default, Charm's signals are _shallowly reactive_, meaning only the value itself is reactive, and table properties are not tracked. Signal values must also be **immutable** in order to track changes in tables.
You can opt-in to deep reactivity with **reactive proxies**. The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and table values will also be wrapped in a reactive proxy.
```luau
local users, setUsers = reactive({
{ name = "John", surname = "Doe" },
})
effect(function()
print(`{users[1].name} {users[1].surname}`)
end) -- Output: John Doe
setUsers(function(state)
state[1].name = "Jane"
state[1].surname = "Smith"
table.insert(state, { name = "Steve", surname = "Doe" })
end) -- Output: Jane Smith
-- You can also mutate the reactive proxy directly:
users[1].name = "John" -- John Smith
```
> [!WARNING]
> Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value.
If you need to access the raw table through the reactive proxy, use the `toRaw()` function:
```luau
local raw = {}
local proxy = reactive(raw)
print(toRaw(proxy) == raw) -- true
```
---
### `atom(initialValue, equals?)`
The `atom` function creates a new reactive signal and returns a single function that acts as both a getter and setter.
@ -655,9 +621,9 @@ print(total()) -- 2
Charm exposes the following global flags to customize behavior:
| Flag | Default | Description |
| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. |
| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. |
| ----------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| strict | `true` if not `O2` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. |
| frozen | `true` if not `O2` | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. |
| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. |
The `strict` and `frozen` flags are automatically enabled in Roblox Studio. More accurately, they are enabled for the Luau optimization levels `O1` and lower.
@ -736,7 +702,7 @@ To sync the client with the server's state, call `client.addSignals` with a tabl
After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates.
```luau
-- Add signal setters, atoms, or reactive objects
-- Add signal setters, atoms, or reactive proxies
client.addSignals({
name = nameStore.setName,
surname = nameStore.setSurname,
@ -931,6 +897,87 @@ This means nilable values may be replaced with `None` in patches, and code worki
---
## Deep Reactivity
By default, Charm's signals are not deeply reactive. This means that only the value itself is tracked, and table properties are not checked for changes. As a result, signal values must be **immutable** in order to detect changes in tables.
With deep reactivity, you can mutate tables directly and detect changes in nested properties without cloning tables. In Charm, this is done by wrapping your tables in proxy tables that handle reactivity for you.
You can opt-in to deep reactivity with **reactive proxies**:
### Installation
```sh
npm install @rbxts/deep-charm
yarn add @rbxts/deep-charm
pnpm add @rbxts/deep-charm
```
```toml
[dependencies]
DeepCharm = "littensy/deep-charm@VERSION"
```
---
### `reactive(initialValue)`
The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and table values will also be wrapped in a reactive proxy.
```luau
local users, updateUsers = reactive({
{ name = "John", surname = "Doe" },
})
effect(function()
print(`{users[1].name} {users[1].surname}`)
end) -- Output: John Doe
updateUsers(function(state)
state[1].name = "Jane"
state[1].surname = "Smith"
table.insert(state, { name = "Steve", surname = "Doe" })
end) -- Output: Jane Smith
-- You can also mutate the reactive proxy directly:
users[1].name = "John" -- John Smith
```
### Mutation vs. update function
Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value, which you can access through the update function returned by `reactive()`.
This update function (`updateUsers()` in the example above) passes the raw table for you to mutate. Once your callback is done executing, the updater will manually notify subscriptions to the reactive proxy and its nested properties. This process does not use metatables, so you should use this for table operations or batching updates.
---
### `toRaw(value)`
If you need to access the raw table through the reactive proxy, use the `toRaw()` function:
```luau
local raw = {}
local proxy = reactive(raw)
print(toRaw(proxy) == raw) -- true
```
---
### `isReactive(value)`
The `isReactive()` function returns `true` if the given value is a reactive proxy by checking its metatable.
```lua
local raw = {}
local proxy = reactive(raw)
print(isReactive(proxy)) -- true
print(isReactive(raw)) -- false
```
---
## Migration
Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that might help you migrate from an older version.
@ -1028,11 +1075,11 @@ end
---
<p align="center">
Charm is released under the <a href="LICENSE.md">MIT License</a>.
Charm is released under the <a href="LICENSE">MIT License</a>.
</p>
<div align="center">
[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md)
[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE)
</div>

View file

@ -1,8 +1,9 @@
local Charm = require("../packages/charm/src")
local DeepCharm = require("../packages/deep-charm/src")
local bench = require("./bench")
local effect = Charm.effect
local reactive = Charm.reactive
local reactive = DeepCharm.reactive
do
local value = reactive({ a = 1 })

View file

@ -4,6 +4,7 @@
"$className": "Folder",
"Charm": { "$path": "packages/charm/src" },
"CharmSync": { "$path": "packages/charm-sync/src" },
"DeepCharm": { "$path": "packages/deep-charm/src" },
"ReactCharm": { "$path": "packages/react-charm/src" },
"VideCharm": { "$path": "packages/vide-charm/src" }
}

View file

@ -20,18 +20,24 @@
"charm": {
"$className": "Folder",
"src": { "$path": "packages/charm/src" },
"__tests__": { "$path": "packages/charm/__tests__" }
"test": { "$path": "packages/charm/test" }
},
"deep-charm": {
"$className": "Folder",
"src": { "$path": "packages/deep-charm/src" },
"test": { "$path": "packages/deep-charm/test" },
"Charm": { "$path": "packages/deep-charm/Charm.luau" }
},
"charm-sync": {
"$className": "Folder",
"src": { "$path": "packages/charm-sync/src" },
"__tests__": { "$path": "packages/charm-sync/__tests__" },
"test": { "$path": "packages/charm-sync/test" },
"Charm": { "$path": "packages/charm-sync/Charm.luau" }
},
"react-charm": {
"$className": "Folder",
"src": { "$path": "packages/react-charm/src" },
"__tests__": { "$path": "packages/react-charm/__tests__" },
"test": { "$path": "packages/react-charm/test" },
"React": { "$path": "packages/react-charm/React.luau" },
"ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" },
"Charm": { "$path": "packages/react-charm/Charm.luau" }
@ -39,7 +45,7 @@
"vide-charm": {
"$className": "Folder",
"src": { "$path": "packages/vide-charm/src" },
"__tests__": { "$path": "packages/vide-charm/__tests__" },
"test": { "$path": "packages/vide-charm/test" },
"Vide": { "$path": "packages/vide-charm/Vide.luau" },
"Charm": { "$path": "packages/vide-charm/Charm.luau" }
}

View file

@ -1,13 +1,13 @@
{
"private": true,
"scripts": {
"test": "lute run test",
"test": "lute run tests",
"build": "lute run build",
"check": "lute run check",
"publish:npm": "pnpm publish -r --access public",
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
},
"packageManager": "pnpm@10.30.1",
"packageManager": "pnpm@10.30.2",
"devDependencies": {
"@eslint/js": "^9.39.2",
"@rbxts/compiler-types": "3.0.0-types.0",

View file

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

View file

@ -11,14 +11,14 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {

View file

@ -2,8 +2,8 @@ local Charm = require("../Charm")
local patch = require("./patch")
local types = require("./types")
type ReactiveProxy<T = {}> = Charm.ReactiveProxy<T>
type SyncPayload = types.SyncPayload
type ReactiveProxy<T = {}> = types.ReactiveProxy<T>
local trackedSignals: { [string]: (any) -> any } = {}
local trackedReactives: { [string]: ReactiveProxy } = {}

View file

@ -4,8 +4,6 @@ local patch = require("./patch")
local types = require("./types")
local utils = require("./utils")
type ReactiveProxy<T = {}> = Charm.ReactiveProxy<T>
type SyncPayload = types.SyncPayload
type SyncStatus = number
@ -36,9 +34,21 @@ local clients: { [any]: ClientKeys } = {}
local connection: RBXScriptConnection?
local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
--[[
Manually converts a reactive proxy to its raw representation to potentially
support custom implementations of reactive proxies.
]]
local function toRaw<T>(value: T): T
if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then
value.__track()
return toRaw(value.__target)
end
return value
end
local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
if trackedStates[key] then
-- Ignore duplicates, but warn against name collisions
-- Warn against name collisions with conflicting signals or proxies
if trackedStates[key].identifier ~= identifier then
warn("[Charm] Attempted to sync different states with the same key:", key)
end
@ -47,12 +57,12 @@ local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
local trackedState: TrackedState<T>
local initialValue = getter()
local rawInitialValue = Charm.toRaw(initialValue)
local rawInitialValue = toRaw(initialValue)
local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
local dispose = Charm.effect(function()
local newValue = getter()
local rawValue = Charm.toRaw(newValue)
local rawValue = toRaw(newValue)
local isReactive = newValue ~= rawValue
if not trackedState then

View file

@ -18,4 +18,13 @@ export type SyncPayload =
]]
export type None = { __none: "__none" }
export type ReactiveProxy<T = any> = typeof(setmetatable(
{} :: {
__track: () -> (),
__target: T,
__signals: { [unknown]: () -> unknown },
},
{} :: { __index: T }
))
return {}

View file

@ -1,4 +1,5 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local client = require("../src/client")
local test = require("../../../tests/test")
@ -38,7 +39,7 @@ test("should merge patch", function()
end)
test("should merge patch (reactive)", function()
local a = Charm.reactive({ b = 1, c = 2 })
local a = DeepCharm.reactive({ b = 1, c = 2 })
local runs = 0
Charm.effect(function()

View file

@ -1,4 +1,5 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local client = require("../src/client")
local config = require("../src/config")
local server = require("../src/server")
@ -13,9 +14,9 @@ end)
test("should replicate server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local serverB = DeepCharm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
local clientB = DeepCharm.reactive({ count = -1 })
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
@ -50,9 +51,9 @@ end)
test("should replicate historical server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local serverB = DeepCharm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
local clientB = DeepCharm.reactive({ count = -1 })
config.preserveHistory = true

View file

@ -1,4 +1,5 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local config = require("../src/config")
local patch = require("../src/patch")
local server = require("../src/server")
@ -66,7 +67,7 @@ end)
test("should replicate partial state patches (reactive)", function()
local client = {}
local a = Charm.reactive({ x = { y = 0 }, z = 0 :: number? })
local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
local target, payloads
server.addSignalsToClient(client, { a = a })
@ -99,7 +100,7 @@ end)
test("should handle nested proxies", function()
local client = {}
local src = Charm.reactive({ (Charm.reactive({})) })
local src = DeepCharm.reactive({ (DeepCharm.reactive({})) })
local payloads: {}? = nil
server.addSignalsToClient(client, { src = src })
@ -288,9 +289,9 @@ test.describe("historical", function()
test("should replicate granular state changes (reactive)", function()
local client = {}
local a = Charm.reactive({ value = 0 })
local b = Charm.reactive({ value = 0 })
local c = Charm.reactive({ value = 0 })
local a = DeepCharm.reactive({ value = 0 })
local b = DeepCharm.reactive({ value = 0 })
local c = DeepCharm.reactive({ value = 0 })
local target, payloads
server.addSignalsToClient(client, { a = a, b = b, c = c })

View file

@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = [
"node_modules",
"__tests__",
"test",
"src/index.d.ts",
"package.json",
"Charm.luau",

View file

@ -11,14 +11,14 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
}
}

View file

@ -272,54 +272,6 @@ export function observe<V, K extends Key>(
fn: (value: V, key: K) => Cleanup | void,
): Cleanup;
/**
* Creates a deep reactive value that tracks properties when they're accessed.
* Returns a proxy object that subscribes to changes on any nested property
* you read, and a function for mutating the original table.
*
* @example
* ```luau
* local users, setUsers = reactive({
* user = { name = "John", surname = "Doe" },
* })
*
* effect(function()
* print(users.user and `{users.user.name} {users.user.surname}`)
* end)
*
* setUsers(function(state)
* state.user.name = "Jane"
* state.user.surname = "Smith"
* end)
* ```
*
* @param initialValue The value to make deeply reactive.
* @returns A proxy table and a function to mutate the original table.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function reactive<T extends object>(
initialValue: T,
): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>;
/**
* Determines if the provided value is a reactive proxy created by `reactive()`.
*
* @param value A value to test.
* @returns `true` if the value is a reactive proxy, `false` otherwise.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function isReactive(value: unknown): boolean;
/**
* Unwraps a reactive proxy to get the original table. If the provided value
* is not a proxy, it is returned as-is.
*
* @param value A reactive proxy or a non-proxy value.
* @returns The unwrapped target value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function toRaw<T>(value: T): T;
/**
* Returns the currently active subscriber (effect or computed node).
*

View file

@ -9,19 +9,17 @@ export type Link = system.Link
export type ReactiveFlags = system.ReactiveFlags
export type ReactiveNode = system.ReactiveNode
local link = system.link
local unlink = system.unlink
local propagate = system.propagate
local checkDirty = system.checkDirty
local shallowPropagate = system.shallowPropagate
local createReactiveSystem = system.createReactiveSystem
type Cleanup = () -> ()
export type Atom<T = any> = (...((T) -> T) | T) -> T
export type ReactiveProxy<T = any> = typeof(setmetatable(
{} :: {
__track: () -> (),
__target: T,
__signals: { [unknown]: () -> unknown },
},
{} :: { __index: T }
))
type EffectScopeNode = ReactiveNode & {
cleanups: { Cleanup },
}
@ -41,36 +39,6 @@ type SignalNode<T = any> = ReactiveNode & {
pendingValue: T,
}
local function inlineTest(): string
return debug.info(1, "n")
end
local IS_O2 = inlineTest() ~= "inlineTest"
local flags = {
--[[
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 = not IS_O2,
--[[
Enforces immutability of tables by deep-freezing table values. Enabled
if the optimization level is below 2, which is true in studio.
]]
frozen = not IS_O2,
--[[
Whether parent effects should track inner effects and clean them up
when the parent effect re-runs. This is usually desirable, but can be
disabled to revert to the old behavior. Defaults to `true`.
]]
trackInnerEffects = true,
}
local ReactiveProxy = {
proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }),
}
-- Bitmask flags for tracking the state of reactive nodes. Flags are combined
-- and checked using bitwise operations for efficiency.
local NONE = 0b0000000 -- Default state
@ -81,13 +49,6 @@ local DIRTY = 0b0010000 -- Value has changed and needs updating
local PENDING = 0b0100000 -- Might be dirty, pending verification
local PEEKING = 0b1000000 -- Blocking dependency tracking
-- Alien Signals reactive system
local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> ()
local unlink: (link: Link, sub: ReactiveNode?) -> Link?
local propagate: (link: Link) -> ()
local checkDirty: (link: Link, sub: ReactiveNode) -> boolean
local shallowPropagate: (link: Link) -> ()
-- Global version counter for dependency link deduplication. Incremented at the
-- start of each effect/computed run to ensure links created in the same cycle
-- are deduplicated.
@ -104,6 +65,30 @@ local batchDepth = 0
-- Dependencies accessed during this time will link to the active subscriber.
local activeSub: ReactiveNode?
local function isO2(): boolean
return debug.info(1, "n") ~= "isO2"
end
local flags = {
--[[
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 = not isO2(),
--[[
Enforces immutability of tables by deep-freezing table values. Enabled
if the optimization level is below 2, which is true in studio.
]]
frozen = not isO2(),
--[[
Whether parent effects should track inner effects and clean them up
when the parent effect re-runs. This is usually desirable, but can be
disabled to revert to the old behavior. Defaults to `true`.
]]
trackInnerEffects = true,
}
--[[
Wraps a function to prevent yielding when in strict mode. If the function
attempts to yield, an error is thrown.
@ -191,8 +176,8 @@ end
]]
local function runCleanups(cleanups: { Cleanup })
for key, cleanup in cleanups do
cleanups[key] = nil
local prevSub = setActiveSub(nil)
cleanups[key] = nil
xpcall(cleanup, function(err)
warn("Error occurred during effect cleanup:", debug.traceback(tostring(err), 2))
end)
@ -325,69 +310,122 @@ local function clearPending(sub: ReactiveNode, flags: ReactiveFlags)
end
--[[
Returns a function that evaluates the computed value when accessed.
Computed values are lazily evaluated and cached unless marked dirty or
pending with dirty dependencies.
Performs a thorough cleanup of the given effect or effect scope:
@param this Computed node
@return Operator to read the computed value
1. Removes all dependency links
2. Unlinks from parent effects/scopes
3. Clears state flags to mark the effect as inactive
4. Runs all registered cleanup functions
@param this Effect node
]]
local function computedOper<T>(this: ComputedNode<T>): () -> T
local function readComputed(): T
local flags = this.flags
local function stopEffect(this: EffectScopeNode)
this.depsTail = nil
this.flags = NONE
if
bit32.btest(flags, DIRTY)
or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or clearPending(this, flags)))
then
if updateComputed(this) then
-- Notify subscribers if the value changed
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
elseif flags == NONE then
-- First access should initialize the computed value
this.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
purgeDeps(this)
local prevSub = setActiveSub(this)
local success, result = pcall(this.getter)
activeSub = prevSub
this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK))
if success then
this.value = result
else
error(result)
end
local sub = this.subs
if sub then
unlink(sub, this)
end
if activeSub then
link(this, activeSub, cycle)
end
return this.value :: T
end
return readComputed
runCleanups(this.cleanups)
end
--[[
Returns functions to read and write a signal's value:
- Setter: Updates the signal's value and notifies subscribers
- Getter: Returns the current value and registers dependency
Updates a signal or computed value and returns whether the value changed.
@param this Signal node
@param equals Optional comparator function to check for value changes
@return Operator to get the signal value
@return Operator to set the signal value
@param node Signal or computed node
@return Boolean indicating whether the value changed
]]
local function signalOper<T>(
this: SignalNode<T>,
local function update(node: ReactiveNode): boolean
if node.depsTail then
return updateComputed(node :: ComputedNode)
else
return updateSignal(node :: SignalNode)
end
end
--[[
Queues an effect for execution, ensuring inner effects are notified in the
correct order.
@param effect Effect node to notify
]]
local function notify(effect: ReactiveNode)
local insertIndex = queuedLength
local firstInsertedIndex = insertIndex
-- Collect inner effects in a chain
repeat
queued[insertIndex] = effect :: EffectNode
insertIndex += 1
effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING))
effect = (effect.subs and effect.subs.sub) :: EffectNode
if not effect or bit32.band(effect.flags, WATCHING) == 0 then
break
end
until false
queuedLength = insertIndex
-- Reverse the collected effects to ensure outer effects run first
while true do
insertIndex -= 1
if firstInsertedIndex >= insertIndex then
break
end
queued[firstInsertedIndex], queued[insertIndex] = queued[insertIndex], queued[firstInsertedIndex]
firstInsertedIndex += 1
end
end
--[[
Called when a node is not being watched by any subscribers. Cleans up the
node's dependencies and performs the necessary cleanup operations:
- Computed signals: Unlink dependencies and mark as dirty for potential future updates
- Effects/scopes: Perform full cleanup to free resources
@param node Reactive node (computed or effect/scope)
]]
local function unwatched(node: ReactiveNode)
if bit32.band(node.flags, MUTABLE) == 0 then
stopEffect(node :: EffectScopeNode)
elseif node.depsTail then
node.depsTail = nil
node.flags = bit32.bor(MUTABLE, DIRTY)
purgeDeps(node)
end
end
--[[
Creates a reactive signal that stores a value and notifies subscribers when
the value changes. The signal provides both a getter and a setter.
@param initialValue Initial value for the signal
@param equals Optional comparator function to check for value changes
@return Getter function to read the signal value
@return Setter function to update the signal value
@see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
]]
local function signal<T>(
initialValue: T,
equals: ((current: T, incoming: T) -> boolean)?
): (() -> T, (((T) -> T) | T) -> T)
local this: SignalNode = {
currentValue = initialValue,
pendingValue = initialValue,
flags = MUTABLE,
}
if flags.frozen then
deepFreeze(initialValue)
end
local function writeSignal(update: ((T) -> T) | T): T
local value: T
@ -458,122 +496,6 @@ local function signalOper<T>(
return readSignal, writeSignal
end
--[[
Returns an effect cleanup operator that disposes of the effect. This
operator performs a thorough cleanup:
1. Removes all dependency links
2. Unlinks from parent effects/scopes
3. Clears state flags to mark the effect as inactive
4. Runs all registered cleanup functions
@param this Effect node
@return Effect cleanup operator
]]
local function effectOper(this: EffectScopeNode): Cleanup
return function()
this.depsTail = nil
this.flags = NONE
purgeDeps(this)
local sub = this.subs
if sub then
unlink(sub, this)
end
runCleanups(this.cleanups)
end
end
--[[
Updates a signal or computed value and returns whether the value changed.
@param node Signal or computed node
@return Boolean indicating whether the value changed
]]
local function update(node: ReactiveNode): boolean
if node.depsTail then
return updateComputed(node :: ComputedNode)
else
return updateSignal(node :: SignalNode)
end
end
--[[
Queues an effect for execution, ensuring inner effects are notified in the
correct order.
@param effect Effect node to notify
]]
local function notify(effect: ReactiveNode)
local insertIndex = queuedLength
local firstInsertedIndex = insertIndex
-- Collect inner effects in a chain
repeat
effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING))
queued[insertIndex] = effect :: EffectNode
insertIndex += 1
effect = (effect.subs and effect.subs.sub) :: EffectNode
if not effect or bit32.band(effect.flags, WATCHING) == 0 then
break
end
until false
queuedLength = insertIndex
-- Reverse the collected effects to ensure outer effects run first
while true do
insertIndex -= 1
if firstInsertedIndex >= insertIndex then
break
end
queued[firstInsertedIndex], queued[insertIndex] = queued[insertIndex], queued[firstInsertedIndex]
firstInsertedIndex += 1
end
end
--[[
Called when a node is not being watched by any subscribers. Cleans up the
node's dependencies and performs the necessary cleanup operations:
- Computed signals: Unlink dependencies and mark as dirty for potential future updates
- Effects/scopes: Perform full cleanup to free resources
@param node Reactive node (computed or effect/scope)
]]
local function unwatched(node: ReactiveNode)
if bit32.band(node.flags, MUTABLE) == 0 then
effectOper(node :: EffectScopeNode)()
elseif node.depsTail then
node.depsTail = nil
node.flags = bit32.bor(MUTABLE, DIRTY)
purgeDeps(node)
end
end
--[[
Creates a reactive signal that stores a value and notifies subscribers when
the value changes. The signal provides both a getter and a setter.
@param initialValue Initial value for the signal
@param equals Optional comparator function to check for value changes
@return Getter function to read the signal value
@return Setter function to update the signal value
@see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
]]
local function signal<T>(
initialValue: T,
equals: ((current: T, incoming: T) -> boolean)?
): (() -> T, (((T) -> T) | T) -> T)
if flags.frozen then
deepFreeze(initialValue)
end
return signalOper({
currentValue = initialValue,
pendingValue = initialValue,
flags = MUTABLE,
}, equals)
end
--[[
Creates a signal and returns a function that can act as both the getter and
setter. Calling the atom with one argument sets its value, while calling it
@ -601,10 +523,50 @@ end
@see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
]]
local function computed<T>(getter: (previousValue: T?) -> T): () -> T
return computedOper({
local this: ComputedNode = {
flags = NONE,
getter = noYield(getter),
})
}
local function readComputed(): T
local flags = this.flags
if
bit32.btest(flags, DIRTY)
or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or clearPending(this, flags)))
then
if updateComputed(this) then
-- Notify subscribers if the value changed
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
elseif flags == NONE then
-- First access should initialize the computed value
this.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
local prevSub = setActiveSub(this)
local success, result = pcall(this.getter)
activeSub = prevSub
this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK))
if success then
this.value = result
else
error(result)
end
end
if activeSub then
link(this, activeSub, cycle)
end
return this.value :: T
end
return readComputed
end
--[[
@ -643,7 +605,9 @@ local function effect(fn: () -> ...Cleanup?): Cleanup
table.insert(this.cleanups, noYield(result))
end
return effectOper(this)
return function()
stopEffect(this)
end
end
--[[
@ -675,7 +639,9 @@ local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup
table.insert(this.cleanups, noYield(result))
end
return effectOper(this)
return function()
stopEffect(this)
end
end
--[[
@ -1000,165 +966,17 @@ local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item:
end)
end
--[[
Determines if the provided value is a reactive proxy created by `reactive()`.
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function isReactive(value: any): boolean
return type(value) == "table" and getmetatable(value) == ReactiveProxy
end
--[[
Unwraps a reactive proxy to get the original table. If the provided value
is not a proxy, it is returned as-is.
@param value A reactive proxy or a non-proxy value
@return The source table if `value` is a reactive proxy
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function toRaw<T>(value: T): T
if isReactive(value) then
local value = value :: ReactiveProxy<T> & T
value.__track()
return toRaw(value.__target)
end
return value
end
--[[
Creates a deep reactive value that tracks properties when they're accessed.
Returns a proxy object that subscribes to changes on any nested property
you read, and a function for mutating the original table.
@example
```luau
local users, setUsers = reactive({
{ name = "John", surname = "Doe" },
})
effect(function()
print(`{users[1].name} {users[1].surname}`)
end)
setUsers(function(state)
state[1].name = "Jane"
state[1].surname = "Smith"
end)
```
@param initialValue Source table to make deeply reactive
@return A proxy table that tracks reads to nested properties
@return A function for batching mutations to the source table
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function reactive<T>(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ())
initialValue = toRaw(initialValue)
local proxy = ReactiveProxy.proxyMap[initialValue]
if not proxy then
local track = computed(function(version: number?)
-- Reference the proxy to avoid GCing while an effect is actively
-- tracking this reactive object.
local _ref = proxy
-- Allow updates from the parent to propagate down when the
-- target is mutated directly through the setter.
if trackParent then
trackParent()
end
return (version or 0) + 1
end)
proxy = setmetatable({
__target = initialValue,
__signals = setmetatable({}, { __mode = "v" }) :: any,
__track = track,
}, ReactiveProxy)
ReactiveProxy.proxyMap[initialValue] = proxy
end
local function setValue(update: ((T) -> ())?)
if update then
update(proxy.__target)
end
trigger(proxy.__track)
end
return proxy :: any, setValue
end
--[[
Wraps a value in `reactive()` if it is a table; otherwise, returns the
value as-is.
@param value Source value to make deeply reactive
@param trackParent Optional tracking function to propagate deep updates
@return A reactive proxy if the value is a table
]]
local function toReactive<T>(value: T, trackParent: (() -> ())?): T
return if type(value) == "table" then reactive(value, trackParent) else value
end
function ReactiveProxy.__index(self: ReactiveProxy, key: any): any
local track = self.__track
local signals = self.__signals
local target = self.__target
if activeSub then
if not signals[key] then
signals[key] = computed(function()
track()
return target[key]
end)
end
signals[key]()
end
return toReactive(target[key], track)
end
function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any)
value = toRaw(value)
if self.__target[key] ~= value then
self.__target[key] = value
trigger(self.__track)
end
end
function ReactiveProxy.__iter(self: ReactiveProxy)
self.__track()
local function nextReactive(target: any, prevKey: any)
local nextKey, nextValue = next(target, prevKey)
return nextKey, toReactive(nextValue, self.__track)
end
return nextReactive, self.__target
end
function ReactiveProxy.__len(self: ReactiveProxy)
self.__track()
return #self.__target
end
link, unlink, propagate, checkDirty, shallowPropagate = system.createReactiveSystem({
update = update,
notify = notify,
unwatched = unwatched,
})
createReactiveSystem(update, notify, unwatched)
return {
-- Core reactive primitives
ReactiveFlags = system.ReactiveFlags,
flags = flags,
atom = atom,
signal = signal,
computed = computed,
effect = effect,
effectScope = effectScope,
trigger = trigger,
-- Charm utilities
flags = flags,
atom = atom,
onCleanup = onCleanup,
untracked = untracked,
peek = peek,
batch = batch,
@ -1169,14 +987,7 @@ return {
getter: () -> { [KI]: VI },
mapper: (item: VI, key: KI) -> (VO, KO)
) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }),
-- Reactive proxies
reactive = reactive,
isReactive = isReactive,
toRaw = toRaw,
-- Advanced API
ReactiveFlags = system.ReactiveFlags,
onCleanup = onCleanup,
getActiveSub = getActiveSub,
setActiveSub = setActiveSub,
startBatch = startBatch,

View file

@ -37,7 +37,7 @@ local DIRTY = 0b0010000
local PENDING = 0b0100000
local PEEKING = 0b1000000
local ReactiveFlags = {
local ReactiveFlags = table.freeze({
None = NONE,
Mutable = MUTABLE,
Watching = WATCHING,
@ -45,18 +45,14 @@ local ReactiveFlags = {
Recursed = RECURSED,
Dirty = DIRTY,
Pending = PENDING,
}
local function createReactiveSystem(props: {
update: (sub: ReactiveNode) -> boolean,
notify: (sub: ReactiveNode) -> (),
unwatched: (sub: ReactiveNode) -> (),
Peeking = PEEKING,
})
local update = props.update
local notify = props.notify
local unwatched = props.unwatched
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
local update: (sub: ReactiveNode) -> boolean
local notify: (sub: ReactiveNode) -> ()
local unwatched: (sub: ReactiveNode) -> ()
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
local link = sub.depsTail
while link do
if link == checkLink then
@ -65,9 +61,9 @@ local function createReactiveSystem(props: {
link = link.prevDep
end
return false
end
end
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
-- DEVIATION: Do not link signals to peeking subscribers
if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then
return
@ -109,9 +105,9 @@ local function createReactiveSystem(props: {
else
dep.subs = newLink
end
end
end
local function unlink(link: Link, sub: ReactiveNode?): Link?
local function unlink(link: Link, sub: ReactiveNode?): Link?
local sub = sub or link.sub
local dep = link.dep
local prevDep = link.prevDep
@ -142,9 +138,9 @@ local function createReactiveSystem(props: {
end
end
return nextDep
end
end
local function propagate(link: Link)
local function propagate(link: Link)
repeat
local sub = link.sub
local flags = sub.flags
@ -176,9 +172,9 @@ local function createReactiveSystem(props: {
link = link.nextSub :: Link
until not link
end
end
local function shallowPropagate(link: Link)
local function shallowPropagate(link: Link)
repeat
local sub = link.sub
local flags = sub.flags
@ -190,9 +186,9 @@ local function createReactiveSystem(props: {
end
link = link.nextSub :: Link
until not link
end
end
local function checkDirty(link: Link, sub: ReactiveNode): boolean
local function checkDirty(link: Link, sub: ReactiveNode): boolean
repeat
local dep = link.dep
local depFlags = dep.flags
@ -225,12 +221,24 @@ local function createReactiveSystem(props: {
until not link
return false
end
end
return link, unlink, propagate, checkDirty, shallowPropagate
local function createReactiveSystem(
updateFn: (sub: ReactiveNode) -> boolean,
notifyFn: (sub: ReactiveNode) -> (),
unwatchedFn: (sub: ReactiveNode) -> ()
)
update = updateFn
notify = notifyFn
unwatched = unwatchedFn
end
return {
ReactiveFlags = ReactiveFlags,
link = link,
unlink = unlink,
propagate = propagate,
checkDirty = checkDirty,
shallowPropagate = shallowPropagate,
createReactiveSystem = createReactiveSystem,
}

View file

@ -1,13 +1,10 @@
local Charm = require("../src")
local test = require("../../../tests/test")
type ReactiveProxy<T = any> = Charm.ReactiveProxy<T>
local signal = Charm.signal
local computed = Charm.computed
local effect = Charm.effect
local effectScope = Charm.effectScope
local reactive = Charm.reactive
local function weakRef<T>(value: T): { value: T? }
return setmetatable({ value = value }, { __mode = "v" }) :: any
@ -65,62 +62,4 @@ test("should not release effect for atom", function()
assert(effectCalls == 2, "should call effect again")
end)
test("should release inner reactive proxy", function()
local valueRef = weakRef({})
local src: any = reactive({ value = valueRef.value })
local dispose = effect(function()
local _ = src.value -- create proxy
end)
src.value = nil :: any -- release proxy
dispose()
gc()
assert(src.__signals.value == nil, "should release signals")
assert(valueRef.value == nil, "should release inner proxy")
end)
test("should release reactive property deps", function()
local src: any = reactive({ foo = 1 })
local c = computed(function()
return src.foo
end)
c()
assert(src.__signals.foo ~= nil, "should have deps for foo")
c = nil :: any
gc()
assert(src.__signals.foo == nil, "should release deps for foo")
end)
test("should not release effect for reactive", function()
local src = reactive({ foo = 1 })
local calls = 0
effect(function()
calls += 1
local _ = src.foo
end)
assert(calls == 1, "should call effect once")
gc()
src.foo += 1
assert(calls == 2, "should call effect again")
end)
test("should not release tracked reactive", function()
local src = Charm.reactive({ users = {} :: { [string]: {}? } })
local user
effect(function()
user = src.users.user
end)
gc()
src.users.user = {}
assert(user ~= nil, "src.users.user should not be released")
end)
return {}

View file

@ -9,7 +9,6 @@ local tests = {
"mapped",
"observe",
"peek",
"reactive",
"signal",
"subscribe",
"topology",

View file

@ -3,6 +3,6 @@ name = "littensy/charm"
version = "0.11.0-rc.4"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["node_modules", "__tests__", "src/index.d.ts", "package.json"]
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
[dependencies]

View file

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

View file

@ -0,0 +1,6 @@
{
"name": "deep-charm",
"tree": {
"$path": "src"
}
}

View file

@ -0,0 +1,27 @@
{
"name": "@rbxts/deep-charm",
"version": "0.1.0-rc.1",
"description": "Use Charm with plain Luau tables",
"license": "MIT",
"main": "src/init.luau",
"types": "src/index.d.ts",
"files": [
"src",
"*.luau",
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {
"@rbxts/charm": "workspace:^"
}
}

53
packages/deep-charm/src/index.d.ts vendored Normal file
View file

@ -0,0 +1,53 @@
export type ReactiveProxy<T extends object = object> = T & {
__track: () => void;
__target: T;
__signals: Map<unknown, () => unknown>;
};
/**
* Creates a deep reactive value that tracks properties when they're accessed.
* Returns a proxy object that subscribes to changes on any nested property
* you read, and a function for mutating the original table.
*
* @example
* ```luau
* local users, updateUsers = reactive({
* user = { name = "John", surname = "Doe" },
* })
*
* effect(function()
* print(users.user and `{users.user.name} {users.user.surname}`)
* end)
*
* updateUsers(function(state)
* state.user.name = "Jane"
* state.user.surname = "Smith"
* end)
* ```
*
* @param initialValue The value to make deeply reactive.
* @returns A proxy table and a function to mutate the original table.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function reactive<T extends object>(
initialValue: T,
): LuaTuple<[value: T, update: (update: T | ((initialValue: T) => void)) => T]>;
/**
* Determines if the provided value is a reactive proxy created by `reactive()`.
*
* @param value A value to test.
* @returns `true` if the value is a reactive proxy, `false` otherwise.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function isReactive(value: unknown): boolean;
/**
* Unwraps a reactive proxy to get the original table. If the provided value
* is not a proxy, it is returned as-is.
*
* @param value A reactive proxy or a non-proxy value.
* @returns The unwrapped target value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function toRaw<T>(value: T): T;

View file

@ -0,0 +1,165 @@
local Charm = require("./Charm")
local computed = Charm.computed
local trigger = Charm.trigger
local getActiveSub = Charm.getActiveSub
export type ReactiveProxy<T = any> = typeof(setmetatable(
{} :: {
__track: () -> (),
__target: T,
__signals: { [unknown]: () -> unknown },
},
{} :: { __index: T }
))
local ReactiveProxy = {
proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }),
}
--[[
Determines if the provided value is a reactive proxy created by `reactive()`.
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function isReactive(value: any): boolean
return type(value) == "table" and getmetatable(value) == ReactiveProxy
end
--[[
Unwraps a reactive proxy to get the original table. If the provided value
is not a proxy, it is returned as-is.
@param value A reactive proxy or a non-proxy value
@return The source table if `value` is a reactive proxy
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function toRaw<T>(value: T): T
if isReactive(value) then
local value = value :: ReactiveProxy<T> & T
value.__track()
return toRaw(value.__target)
end
return value
end
--[[
Creates a deep reactive value that tracks properties when they're accessed.
Returns a proxy object that subscribes to changes on any nested property
you read, and a function for mutating the original table.
@example
```luau
local users, updateUsers = reactive({
{ name = "John", surname = "Doe" },
})
effect(function()
print(`{users[1].name} {users[1].surname}`)
end)
updateUsers(function(state)
state[1].name = "Jane"
state[1].surname = "Smith"
end)
```
@param initialValue Source table to make deeply reactive
@return A proxy table that tracks reads to nested properties
@return A function for batching mutations to the source table
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function reactive<T>(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ())
initialValue = toRaw(initialValue)
local proxy = ReactiveProxy.proxyMap[initialValue]
if not proxy then
local track = computed(function(version: number?)
-- Reference the proxy to avoid GCing while an effect is actively
-- tracking this reactive object.
local _ref = proxy
-- Allow updates from the parent to propagate down when the
-- target is mutated directly through the setter.
if trackParent then
trackParent()
end
return (version or 0) + 1
end)
proxy = setmetatable({
__target = initialValue,
__signals = setmetatable({}, { __mode = "v" }) :: any,
__track = track,
}, ReactiveProxy)
ReactiveProxy.proxyMap[initialValue] = proxy
end
local function setValue(update: ((T) -> ())?)
if update then
update(proxy.__target)
end
trigger(proxy.__track)
end
return proxy :: any, setValue
end
--[[
Wraps a value in `reactive()` if it is a table; otherwise, returns the
value as-is.
@param value Source value to make deeply reactive
@param trackParent Optional tracking function to propagate deep updates
@return A reactive proxy if the value is a table
]]
local function toReactive<T>(value: T, trackParent: (() -> ())?): T
return if type(value) == "table" then reactive(value, trackParent) else value
end
function ReactiveProxy.__index(self: ReactiveProxy, key: any): any
local track = self.__track
local signals = self.__signals
local target = self.__target
if getActiveSub() then
if not signals[key] then
signals[key] = computed(function()
track()
return target[key]
end)
end
signals[key]()
end
return toReactive(target[key], track)
end
function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any)
value = toRaw(value)
if self.__target[key] ~= value then
self.__target[key] = value
trigger(self.__track)
end
end
function ReactiveProxy.__iter(self: ReactiveProxy)
self.__track()
local function nextReactive(target: any, prevKey: any)
local nextKey, nextValue = next(target, prevKey)
return nextKey, toReactive(nextValue, self.__track)
end
return nextReactive, self.__target
end
function ReactiveProxy.__len(self: ReactiveProxy)
self.__track()
return #self.__target
end
return {
reactive = reactive,
isReactive = isReactive,
toRaw = toRaw,
}

View file

@ -0,0 +1,79 @@
local Charm = require("../Charm")
local DeepCharm = require("../src")
local test = require("../../../tests/test")
type ReactiveProxy<T = any> = DeepCharm.ReactiveProxy<T>
local computed = Charm.computed
local effect = Charm.effect
local reactive = DeepCharm.reactive
local function weakRef<T>(value: T): { value: T? }
return setmetatable({ value = value }, { __mode = "v" }) :: any
end
local function gc()
for _ = 1, 2000 do
local _ = table.create(100, 0)
end
end
test("should release inner reactive proxy", function()
local valueRef = weakRef({})
local src: any = reactive({ value = valueRef.value })
local dispose = effect(function()
local _ = src.value -- create proxy
end)
src.value = nil :: any -- release proxy
dispose()
gc()
assert(src.__signals.value == nil, "should release signals")
assert(valueRef.value == nil, "should release inner proxy")
end)
test("should release reactive property deps", function()
local src: any = reactive({ foo = 1 })
local c = computed(function()
return src.foo
end)
c()
assert(src.__signals.foo ~= nil, "should have deps for foo")
c = nil :: any
gc()
assert(src.__signals.foo == nil, "should release deps for foo")
end)
test("should not release effect for reactive", function()
local src = reactive({ foo = 1 })
local calls = 0
effect(function()
calls += 1
local _ = src.foo
end)
assert(calls == 1, "should call effect once")
gc()
src.foo += 1
assert(calls == 2, "should call effect again")
end)
test("should not release tracked reactive", function()
local src = reactive({ users = {} :: { [string]: {}? } })
local user
effect(function()
user = src.users.user
end)
gc()
src.users.user = {}
assert(user ~= nil, "src.users.user should not be released")
end)
return {}

View file

@ -0,0 +1,11 @@
local test = require("../../tests/test")
test.describe("reactive", function()
require(`@self/reactive.test`)
end)
test.describe("gc", function()
require(`@self/gc.test`)
end)
return {}

View file

@ -1,9 +1,10 @@
local Charm = require("../src")
local Charm = require("../Charm")
local DeepCharm = require("../src")
local test = require("../../../tests/test")
local effect = Charm.effect
local reactive = Charm.reactive
local toRaw = Charm.toRaw
local reactive = DeepCharm.reactive
local toRaw = DeepCharm.toRaw
test("should track nested properties", function()
local src, setSrc = reactive({ a = { b = { c = 0 } } })

View file

@ -0,0 +1,9 @@
[package]
name = "littensy/deep-charm"
version = "0.1.0-rc.1"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
[dependencies]
Charm = "littensy/charm@^0.11.0-rc.3"

View file

@ -11,14 +11,14 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {

View file

@ -51,8 +51,8 @@ local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?)
return binding
end
return {
return table.freeze({
useSignalState = useSignalState,
useSignalBinding = useSignalBinding,
useAtom = useSignalState,
}
})

View file

@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = [
"node_modules",
"__tests__",
"test",
"src/index.d.ts",
"package.json",
"React.luau",

View file

@ -11,14 +11,14 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {

View file

@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = [
"node_modules",
"__tests__",
"test",
"src/index.d.ts",
"package.json",
"Vide.luau",

6
pnpm-lock.yaml generated
View file

@ -65,6 +65,12 @@ importers:
specifier: workspace:^
version: link:../charm
packages/deep-charm:
dependencies:
'@rbxts/charm':
specifier: workspace:^
version: link:../charm
packages/react-charm:
dependencies:
'@rbxts/charm':

1
tests/init.luau Normal file
View file

@ -0,0 +1 @@
(require)("@self/run.server")

View file

@ -5,23 +5,27 @@ Charm.flags.strict = true
Charm.flags.frozen = true
local describeInRoblox = test.describeIf(function()
return game :: any ~= nil
return not not game
end)
test.describe("charm", function()
require("../packages/charm/__tests__")
require("../packages/charm/test")
end)
test.describe("charm-sync", function()
require("../packages/charm-sync/__tests__")
require("../packages/charm-sync/test")
end)
test.describe("deep-charm", function()
require("../packages/deep-charm/test")
end)
describeInRoblox("react-charm", function()
require("../packages/react-charm/__tests__")
require("../packages/react-charm/test")
end)
describeInRoblox("vide-charm", function()
require("../packages/vide-charm/__tests__")
require("../packages/vide-charm/test")
end)
test.finish()