From 89478f679c7578b64799852ad4c2555408c42bab Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:49:34 -0800 Subject: [PATCH] refactor: reorganize repo, create deep-charm --- .github/workflows/ci.yml | 2 +- .gitignore | 2 +- .lute/test.luau | 1 - LICENSE | 21 + LICENSE.md | 7 - README.md | 145 +++-- benches/reactive.luau | 3 +- build.project.json | 1 + default.project.json | 14 +- package.json | 4 +- packages/charm-sync/Charm.luau | 1 - packages/charm-sync/package.json | 4 +- packages/charm-sync/src/client.luau | 2 +- packages/charm-sync/src/server.luau | 20 +- packages/charm-sync/src/types.luau | 9 + .../{__tests__ => test}/client.test.luau | 3 +- .../{__tests__ => test}/e2e.test.luau | 9 +- .../charm-sync/{__tests__ => test}/init.luau | 0 .../{__tests__ => test}/patch.test.luau | 0 .../{__tests__ => test}/server.test.luau | 11 +- .../{__tests__ => test}/utils.test.luau | 0 packages/charm-sync/wally.toml | 2 +- packages/charm/package.json | 4 +- packages/charm/src/index.d.ts | 48 -- packages/charm/src/init.luau | 565 ++++++------------ packages/charm/src/system.luau | 312 +++++----- .../{__tests__ => test}/computed.test.luau | 0 .../{__tests__ => test}/effect.test.luau | 0 .../{__tests__ => test}/effectScope.test.luau | 0 .../charm/{__tests__ => test}/gc.test.luau | 61 -- packages/charm/{__tests__ => test}/init.luau | 1 - .../{__tests__ => test}/listen.test.luau | 0 .../{__tests__ => test}/mapped.test.luau | 0 .../{__tests__ => test}/observe.test.luau | 0 .../charm/{__tests__ => test}/peek.test.luau | 0 .../{__tests__ => test}/signal.test.luau | 0 .../{__tests__ => test}/subscribe.test.luau | 0 .../{__tests__ => test}/topology.test.luau | 0 .../{__tests__ => test}/trigger.test.luau | 0 .../{__tests__ => test}/untracked.test.luau | 0 packages/charm/wally.toml | 2 +- packages/deep-charm/Charm.luau | 9 + packages/deep-charm/default.project.json | 6 + packages/deep-charm/package.json | 27 + packages/deep-charm/src/index.d.ts | 53 ++ packages/deep-charm/src/init.luau | 165 +++++ packages/deep-charm/test/gc.test.luau | 79 +++ packages/deep-charm/test/init.luau | 11 + .../test}/reactive.test.luau | 7 +- packages/deep-charm/wally.toml | 9 + packages/react-charm/package.json | 4 +- packages/react-charm/src/init.luau | 4 +- .../helpers/renderHook.luau | 0 .../react-charm/{__tests__ => test}/init.luau | 0 .../useSignalBinding.test.luau | 0 .../useSignalState.test.luau | 0 packages/react-charm/wally.toml | 2 +- packages/vide-charm/package.json | 4 +- .../vide-charm/{__tests__ => test}/init.luau | 0 .../useSignalState.test.luau | 0 packages/vide-charm/wally.toml | 2 +- pnpm-lock.yaml | 6 + tests/init.luau | 1 + tests/run.server.luau | 14 +- 64 files changed, 913 insertions(+), 744 deletions(-) delete mode 100644 .lute/test.luau create mode 100644 LICENSE delete mode 100644 LICENSE.md rename packages/charm-sync/{__tests__ => test}/client.test.luau (96%) rename packages/charm-sync/{__tests__ => test}/e2e.test.luau (90%) rename packages/charm-sync/{__tests__ => test}/init.luau (100%) rename packages/charm-sync/{__tests__ => test}/patch.test.luau (100%) rename packages/charm-sync/{__tests__ => test}/server.test.luau (97%) rename packages/charm-sync/{__tests__ => test}/utils.test.luau (100%) rename packages/charm/{__tests__ => test}/computed.test.luau (100%) rename packages/charm/{__tests__ => test}/effect.test.luau (100%) rename packages/charm/{__tests__ => test}/effectScope.test.luau (100%) rename packages/charm/{__tests__ => test}/gc.test.luau (54%) rename packages/charm/{__tests__ => test}/init.luau (96%) rename packages/charm/{__tests__ => test}/listen.test.luau (100%) rename packages/charm/{__tests__ => test}/mapped.test.luau (100%) rename packages/charm/{__tests__ => test}/observe.test.luau (100%) rename packages/charm/{__tests__ => test}/peek.test.luau (100%) rename packages/charm/{__tests__ => test}/signal.test.luau (100%) rename packages/charm/{__tests__ => test}/subscribe.test.luau (100%) rename packages/charm/{__tests__ => test}/topology.test.luau (100%) rename packages/charm/{__tests__ => test}/trigger.test.luau (100%) rename packages/charm/{__tests__ => test}/untracked.test.luau (100%) create mode 100644 packages/deep-charm/Charm.luau create mode 100644 packages/deep-charm/default.project.json create mode 100644 packages/deep-charm/package.json create mode 100644 packages/deep-charm/src/index.d.ts create mode 100644 packages/deep-charm/src/init.luau create mode 100644 packages/deep-charm/test/gc.test.luau create mode 100644 packages/deep-charm/test/init.luau rename packages/{charm/__tests__ => deep-charm/test}/reactive.test.luau (96%) create mode 100644 packages/deep-charm/wally.toml rename packages/react-charm/{__tests__ => test}/helpers/renderHook.luau (100%) rename packages/react-charm/{__tests__ => test}/init.luau (100%) rename packages/react-charm/{__tests__ => test}/useSignalBinding.test.luau (100%) rename packages/react-charm/{__tests__ => test}/useSignalState.test.luau (100%) rename packages/vide-charm/{__tests__ => test}/init.luau (100%) rename packages/vide-charm/{__tests__ => test}/useSignalState.test.luau (100%) create mode 100644 tests/init.luau diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b5865f..5cd5741 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 52e0af1..d217e96 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ /.pesde /build /packages/**/wally.lock -/packages/**/LICENSE.md +/packages/**/LICENSE* /packages/**/README.md /sourcemap.json node_modules diff --git a/.lute/test.luau b/.lute/test.luau deleted file mode 100644 index 4abbbb9..0000000 --- a/.lute/test.luau +++ /dev/null @@ -1 +0,0 @@ -(require)("../tests/run.server") diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d10b611 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index ae1bb3d..0000000 --- a/LICENSE.md +++ /dev/null @@ -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. diff --git a/README.md b/README.md index 867b06c..814b92e 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. @@ -654,11 +620,11 @@ 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. | -| 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. | +| Flag | Default | Description | +| ----------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 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 ---

-Charm is released under the MIT License. +Charm is released under the MIT License.

-[![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)
diff --git a/benches/reactive.luau b/benches/reactive.luau index 2e499ca..c272651 100644 --- a/benches/reactive.luau +++ b/benches/reactive.luau @@ -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 }) diff --git a/build.project.json b/build.project.json index 8141b86..53d32b4 100644 --- a/build.project.json +++ b/build.project.json @@ -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" } } diff --git a/default.project.json b/default.project.json index b9c6147..85d8f55 100644 --- a/default.project.json +++ b/default.project.json @@ -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" } } diff --git a/package.json b/package.json index 07b26da..c2815eb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau index a589bde..9a55fe0 100644 --- a/packages/charm-sync/Charm.luau +++ b/packages/charm-sync/Charm.luau @@ -5,6 +5,5 @@ end local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom -export type ReactiveProxy = Charm.ReactiveProxy return Charm diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index 49bc0bc..db09da2 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -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": { diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index e02de4b..165bbc6 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -2,8 +2,8 @@ local Charm = require("../Charm") local patch = require("./patch") local types = require("./types") -type ReactiveProxy = Charm.ReactiveProxy type SyncPayload = types.SyncPayload +type ReactiveProxy = types.ReactiveProxy local trackedSignals: { [string]: (any) -> any } = {} local trackedReactives: { [string]: ReactiveProxy } = {} diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 3a55854..ce6fc9e 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -4,8 +4,6 @@ local patch = require("./patch") local types = require("./types") local utils = require("./utils") -type ReactiveProxy = Charm.ReactiveProxy - 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(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(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(key: string, getter: () -> T, identifier: unknown) local trackedState: TrackedState 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 diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index 20af1f3..664ee61 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -18,4 +18,13 @@ export type SyncPayload = ]] export type None = { __none: "__none" } +export type ReactiveProxy = typeof(setmetatable( + {} :: { + __track: () -> (), + __target: T, + __signals: { [unknown]: () -> unknown }, + }, + {} :: { __index: T } +)) + return {} diff --git a/packages/charm-sync/__tests__/client.test.luau b/packages/charm-sync/test/client.test.luau similarity index 96% rename from packages/charm-sync/__tests__/client.test.luau rename to packages/charm-sync/test/client.test.luau index 6cda992..20c41a8 100644 --- a/packages/charm-sync/__tests__/client.test.luau +++ b/packages/charm-sync/test/client.test.luau @@ -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() diff --git a/packages/charm-sync/__tests__/e2e.test.luau b/packages/charm-sync/test/e2e.test.luau similarity index 90% rename from packages/charm-sync/__tests__/e2e.test.luau rename to packages/charm-sync/test/e2e.test.luau index 36227b2..62ca4a7 100644 --- a/packages/charm-sync/__tests__/e2e.test.luau +++ b/packages/charm-sync/test/e2e.test.luau @@ -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 diff --git a/packages/charm-sync/__tests__/init.luau b/packages/charm-sync/test/init.luau similarity index 100% rename from packages/charm-sync/__tests__/init.luau rename to packages/charm-sync/test/init.luau diff --git a/packages/charm-sync/__tests__/patch.test.luau b/packages/charm-sync/test/patch.test.luau similarity index 100% rename from packages/charm-sync/__tests__/patch.test.luau rename to packages/charm-sync/test/patch.test.luau diff --git a/packages/charm-sync/__tests__/server.test.luau b/packages/charm-sync/test/server.test.luau similarity index 97% rename from packages/charm-sync/__tests__/server.test.luau rename to packages/charm-sync/test/server.test.luau index 559c4ce..02a9af3 100644 --- a/packages/charm-sync/__tests__/server.test.luau +++ b/packages/charm-sync/test/server.test.luau @@ -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 }) diff --git a/packages/charm-sync/__tests__/utils.test.luau b/packages/charm-sync/test/utils.test.luau similarity index 100% rename from packages/charm-sync/__tests__/utils.test.luau rename to packages/charm-sync/test/utils.test.luau diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index c266da8..55d4eea 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -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", diff --git a/packages/charm/package.json b/packages/charm/package.json index feb5e93..92079ec 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -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" } } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index f1941e8..554c327 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -272,54 +272,6 @@ export function observe( 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( - 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(value: T): T; - /** * Returns the currently active subscriber (effect or computed node). * diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index ff30b1f..650eced 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -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) -> T) | T) -> T -export type ReactiveProxy = typeof(setmetatable( - {} :: { - __track: () -> (), - __target: T, - __signals: { [unknown]: () -> unknown }, - }, - {} :: { __index: T } -)) - type EffectScopeNode = ReactiveNode & { cleanups: { Cleanup }, } @@ -41,36 +39,6 @@ type SignalNode = 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(this: ComputedNode): () -> 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 - end - - if activeSub then - link(this, activeSub, cycle) - end - - return this.value :: T + local sub = this.subs + if sub then + unlink(sub, this) 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( - this: SignalNode, +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( + 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( 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( - 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(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(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(value: T): T - if isReactive(value) then - local value = value :: ReactiveProxy & 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(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(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 }) & ((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, diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index 64f6096..d67efb5 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -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,161 +45,166 @@ 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 link = sub.depsTail - while link do - if link == checkLink then - return true - end - link = link.prevDep +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 + return true end - return false + link = link.prevDep end + return false +end - 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 - end - local prevDep = sub.depsTail - if prevDep and prevDep.dep == dep then - return - end - local nextDep = if prevDep then prevDep.nextDep else sub.deps - if nextDep and nextDep.dep == dep then - nextDep.version = version - sub.depsTail = nextDep - return - end - local prevSub = dep.subsTail - if prevSub and prevSub.version == version and prevSub.sub == sub then - return - end - local newLink: Link = { - version = version, - dep = dep, - sub = sub, - prevDep = prevDep, - nextDep = nextDep, - prevSub = prevSub, - } - sub.depsTail = newLink - dep.subsTail = newLink - if nextDep then - nextDep.prevDep = newLink - end - if prevDep then - prevDep.nextDep = newLink - else - sub.deps = newLink - end - if prevSub then - prevSub.nextSub = newLink - else - dep.subs = newLink +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 + end + local prevDep = sub.depsTail + if prevDep and prevDep.dep == dep then + return + end + local nextDep = if prevDep then prevDep.nextDep else sub.deps + if nextDep and nextDep.dep == dep then + nextDep.version = version + sub.depsTail = nextDep + return + end + local prevSub = dep.subsTail + if prevSub and prevSub.version == version and prevSub.sub == sub then + return + end + local newLink: Link = { + version = version, + dep = dep, + sub = sub, + prevDep = prevDep, + nextDep = nextDep, + prevSub = prevSub, + } + sub.depsTail = newLink + dep.subsTail = newLink + if nextDep then + nextDep.prevDep = newLink + end + if prevDep then + prevDep.nextDep = newLink + else + sub.deps = newLink + end + if prevSub then + prevSub.nextSub = newLink + else + dep.subs = newLink + end +end + +local function unlink(link: Link, sub: ReactiveNode?): Link? + local sub = sub or link.sub + local dep = link.dep + local prevDep = link.prevDep + local nextDep = link.nextDep + local nextSub = link.nextSub + local prevSub = link.prevSub + if nextDep then + nextDep.prevDep = prevDep + else + sub.depsTail = prevDep + end + if prevDep then + prevDep.nextDep = nextDep + else + sub.deps = nextDep + end + if nextSub then + nextSub.prevSub = prevSub + else + dep.subsTail = prevSub + end + if prevSub then + prevSub.nextSub = nextSub + else + dep.subs = nextSub + if not nextSub then + unwatched(dep) end end + return nextDep +end - local function unlink(link: Link, sub: ReactiveNode?): Link? - local sub = sub or link.sub - local dep = link.dep - local prevDep = link.prevDep - local nextDep = link.nextDep - local nextSub = link.nextSub - local prevSub = link.prevSub - if nextDep then - nextDep.prevDep = prevDep +local function propagate(link: Link) + repeat + local sub = link.sub + local flags = sub.flags + + if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then + sub.flags = bit32.bor(flags, PENDING) + elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then + flags = NONE + elseif bit32.band(flags, RECURSED_CHECK) == 0 then + sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) + elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then + sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) + flags = bit32.band(flags, MUTABLE) else - sub.depsTail = prevDep + -- selene: allow(if_same_then_else) + flags = NONE end - if prevDep then - prevDep.nextDep = nextDep - else - sub.deps = nextDep + + if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then + notify(sub) end - if nextSub then - nextSub.prevSub = prevSub - else - dep.subsTail = prevSub - end - if prevSub then - prevSub.nextSub = nextSub - else - dep.subs = nextSub - if not nextSub then - unwatched(dep) + + if bit32.btest(flags, MUTABLE) then + local subSubs = sub.subs + if subSubs then + propagate(subSubs) end end - return nextDep - end - local function propagate(link: Link) - repeat - local sub = link.sub - local flags = sub.flags - - if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then - sub.flags = bit32.bor(flags, PENDING) - elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then - flags = NONE - elseif bit32.band(flags, RECURSED_CHECK) == 0 then - sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) - elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then - sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) - flags = bit32.band(flags, MUTABLE) - else - -- selene: allow(if_same_then_else) - flags = NONE - end + link = link.nextSub :: Link + until not link +end +local function shallowPropagate(link: Link) + repeat + local sub = link.sub + local flags = sub.flags + if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then + sub.flags = bit32.bor(flags, DIRTY) if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then notify(sub) end + end + link = link.nextSub :: Link + until not link +end - if bit32.btest(flags, MUTABLE) then - local subSubs = sub.subs - if subSubs then - propagate(subSubs) +local function checkDirty(link: Link, sub: ReactiveNode): boolean + repeat + local dep = link.dep + local depFlags = dep.flags + + if bit32.btest(sub.flags, DIRTY) then + return true + elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then + if update(dep) then + local subs = dep.subs :: Link + if subs.nextSub then + shallowPropagate(subs) end - end - - link = link.nextSub :: Link - until not link - end - - local function shallowPropagate(link: Link) - repeat - local sub = link.sub - local flags = sub.flags - if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then - sub.flags = bit32.bor(flags, DIRTY) - if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then - notify(sub) - end - end - link = link.nextSub :: Link - until not link - end - - local function checkDirty(link: Link, sub: ReactiveNode): boolean - repeat - local dep = link.dep - local depFlags = dep.flags - - if bit32.btest(sub.flags, DIRTY) then return true - elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then + end + elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then + if checkDirty(dep.deps :: Link, dep) then if update(dep) then local subs = dep.subs :: Link if subs.nextSub then @@ -207,30 +212,33 @@ local function createReactiveSystem(props: { end return true end - elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then - if checkDirty(dep.deps :: Link, dep) then - if update(dep) then - local subs = dep.subs :: Link - if subs.nextSub then - shallowPropagate(subs) - end - return true - end - else - dep.flags = bit32.band(depFlags, bit32.bnot(PENDING)) - end + else + dep.flags = bit32.band(depFlags, bit32.bnot(PENDING)) end + end - link = link.nextDep :: Link - until not link + link = link.nextDep :: Link + until not link - return false - end + return false +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, } diff --git a/packages/charm/__tests__/computed.test.luau b/packages/charm/test/computed.test.luau similarity index 100% rename from packages/charm/__tests__/computed.test.luau rename to packages/charm/test/computed.test.luau diff --git a/packages/charm/__tests__/effect.test.luau b/packages/charm/test/effect.test.luau similarity index 100% rename from packages/charm/__tests__/effect.test.luau rename to packages/charm/test/effect.test.luau diff --git a/packages/charm/__tests__/effectScope.test.luau b/packages/charm/test/effectScope.test.luau similarity index 100% rename from packages/charm/__tests__/effectScope.test.luau rename to packages/charm/test/effectScope.test.luau diff --git a/packages/charm/__tests__/gc.test.luau b/packages/charm/test/gc.test.luau similarity index 54% rename from packages/charm/__tests__/gc.test.luau rename to packages/charm/test/gc.test.luau index a03acac..2d53389 100644 --- a/packages/charm/__tests__/gc.test.luau +++ b/packages/charm/test/gc.test.luau @@ -1,13 +1,10 @@ local Charm = require("../src") local test = require("../../../tests/test") -type ReactiveProxy = Charm.ReactiveProxy - local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local effectScope = Charm.effectScope -local reactive = Charm.reactive local function weakRef(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 {} diff --git a/packages/charm/__tests__/init.luau b/packages/charm/test/init.luau similarity index 96% rename from packages/charm/__tests__/init.luau rename to packages/charm/test/init.luau index 6c443b0..bd58a2f 100644 --- a/packages/charm/__tests__/init.luau +++ b/packages/charm/test/init.luau @@ -9,7 +9,6 @@ local tests = { "mapped", "observe", "peek", - "reactive", "signal", "subscribe", "topology", diff --git a/packages/charm/__tests__/listen.test.luau b/packages/charm/test/listen.test.luau similarity index 100% rename from packages/charm/__tests__/listen.test.luau rename to packages/charm/test/listen.test.luau diff --git a/packages/charm/__tests__/mapped.test.luau b/packages/charm/test/mapped.test.luau similarity index 100% rename from packages/charm/__tests__/mapped.test.luau rename to packages/charm/test/mapped.test.luau diff --git a/packages/charm/__tests__/observe.test.luau b/packages/charm/test/observe.test.luau similarity index 100% rename from packages/charm/__tests__/observe.test.luau rename to packages/charm/test/observe.test.luau diff --git a/packages/charm/__tests__/peek.test.luau b/packages/charm/test/peek.test.luau similarity index 100% rename from packages/charm/__tests__/peek.test.luau rename to packages/charm/test/peek.test.luau diff --git a/packages/charm/__tests__/signal.test.luau b/packages/charm/test/signal.test.luau similarity index 100% rename from packages/charm/__tests__/signal.test.luau rename to packages/charm/test/signal.test.luau diff --git a/packages/charm/__tests__/subscribe.test.luau b/packages/charm/test/subscribe.test.luau similarity index 100% rename from packages/charm/__tests__/subscribe.test.luau rename to packages/charm/test/subscribe.test.luau diff --git a/packages/charm/__tests__/topology.test.luau b/packages/charm/test/topology.test.luau similarity index 100% rename from packages/charm/__tests__/topology.test.luau rename to packages/charm/test/topology.test.luau diff --git a/packages/charm/__tests__/trigger.test.luau b/packages/charm/test/trigger.test.luau similarity index 100% rename from packages/charm/__tests__/trigger.test.luau rename to packages/charm/test/trigger.test.luau diff --git a/packages/charm/__tests__/untracked.test.luau b/packages/charm/test/untracked.test.luau similarity index 100% rename from packages/charm/__tests__/untracked.test.luau rename to packages/charm/test/untracked.test.luau diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index f1d9bb9..1578c0a 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -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] diff --git a/packages/deep-charm/Charm.luau b/packages/deep-charm/Charm.luau new file mode 100644 index 0000000..9a55fe0 --- /dev/null +++ b/packages/deep-charm/Charm.luau @@ -0,0 +1,9 @@ +if not script then + return require("../charm/src") +end + +local Charm = require(script.Parent.Parent.charm.src) + +export type Atom = Charm.Atom + +return Charm diff --git a/packages/deep-charm/default.project.json b/packages/deep-charm/default.project.json new file mode 100644 index 0000000..1937acb --- /dev/null +++ b/packages/deep-charm/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "deep-charm", + "tree": { + "$path": "src" + } +} diff --git a/packages/deep-charm/package.json b/packages/deep-charm/package.json new file mode 100644 index 0000000..96d06af --- /dev/null +++ b/packages/deep-charm/package.json @@ -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:^" + } +} diff --git a/packages/deep-charm/src/index.d.ts b/packages/deep-charm/src/index.d.ts new file mode 100644 index 0000000..dc1aa58 --- /dev/null +++ b/packages/deep-charm/src/index.d.ts @@ -0,0 +1,53 @@ +export type ReactiveProxy = T & { + __track: () => void; + __target: T; + __signals: Map 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( + 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(value: T): T; diff --git a/packages/deep-charm/src/init.luau b/packages/deep-charm/src/init.luau new file mode 100644 index 0000000..ef9ab05 --- /dev/null +++ b/packages/deep-charm/src/init.luau @@ -0,0 +1,165 @@ +local Charm = require("./Charm") + +local computed = Charm.computed +local trigger = Charm.trigger +local getActiveSub = Charm.getActiveSub + +export type ReactiveProxy = 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(value: T): T + if isReactive(value) then + local value = value :: ReactiveProxy & 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(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(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, +} diff --git a/packages/deep-charm/test/gc.test.luau b/packages/deep-charm/test/gc.test.luau new file mode 100644 index 0000000..4fe9f3d --- /dev/null +++ b/packages/deep-charm/test/gc.test.luau @@ -0,0 +1,79 @@ +local Charm = require("../Charm") +local DeepCharm = require("../src") +local test = require("../../../tests/test") + +type ReactiveProxy = DeepCharm.ReactiveProxy + +local computed = Charm.computed +local effect = Charm.effect +local reactive = DeepCharm.reactive + +local function weakRef(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 {} diff --git a/packages/deep-charm/test/init.luau b/packages/deep-charm/test/init.luau new file mode 100644 index 0000000..b7123d6 --- /dev/null +++ b/packages/deep-charm/test/init.luau @@ -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 {} diff --git a/packages/charm/__tests__/reactive.test.luau b/packages/deep-charm/test/reactive.test.luau similarity index 96% rename from packages/charm/__tests__/reactive.test.luau rename to packages/deep-charm/test/reactive.test.luau index 82b37c4..4b747c9 100644 --- a/packages/charm/__tests__/reactive.test.luau +++ b/packages/deep-charm/test/reactive.test.luau @@ -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 } } }) diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml new file mode 100644 index 0000000..7ef15ca --- /dev/null +++ b/packages/deep-charm/wally.toml @@ -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" diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index ae62f9e..4976c98 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -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": { diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 931a584..b626b3f 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -51,8 +51,8 @@ local function useSignalBinding(getter: () -> T, dependencies: { any }?) return binding end -return { +return table.freeze({ useSignalState = useSignalState, useSignalBinding = useSignalBinding, useAtom = useSignalState, -} +}) diff --git a/packages/react-charm/__tests__/helpers/renderHook.luau b/packages/react-charm/test/helpers/renderHook.luau similarity index 100% rename from packages/react-charm/__tests__/helpers/renderHook.luau rename to packages/react-charm/test/helpers/renderHook.luau diff --git a/packages/react-charm/__tests__/init.luau b/packages/react-charm/test/init.luau similarity index 100% rename from packages/react-charm/__tests__/init.luau rename to packages/react-charm/test/init.luau diff --git a/packages/react-charm/__tests__/useSignalBinding.test.luau b/packages/react-charm/test/useSignalBinding.test.luau similarity index 100% rename from packages/react-charm/__tests__/useSignalBinding.test.luau rename to packages/react-charm/test/useSignalBinding.test.luau diff --git a/packages/react-charm/__tests__/useSignalState.test.luau b/packages/react-charm/test/useSignalState.test.luau similarity index 100% rename from packages/react-charm/__tests__/useSignalState.test.luau rename to packages/react-charm/test/useSignalState.test.luau diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index d04375a..3a06c66 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -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", diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 1c96512..6a7ed72 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -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": { diff --git a/packages/vide-charm/__tests__/init.luau b/packages/vide-charm/test/init.luau similarity index 100% rename from packages/vide-charm/__tests__/init.luau rename to packages/vide-charm/test/init.luau diff --git a/packages/vide-charm/__tests__/useSignalState.test.luau b/packages/vide-charm/test/useSignalState.test.luau similarity index 100% rename from packages/vide-charm/__tests__/useSignalState.test.luau rename to packages/vide-charm/test/useSignalState.test.luau diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 3b9e5fe..fb4d378 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8c7bee..066affe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/tests/init.luau b/tests/init.luau new file mode 100644 index 0000000..823aed6 --- /dev/null +++ b/tests/init.luau @@ -0,0 +1 @@ +(require)("@self/run.server") diff --git a/tests/run.server.luau b/tests/run.server.luau index a819a4a..44015cb 100644 --- a/tests/run.server.luau +++ b/tests/run.server.luau @@ -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()