mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
refactor: reorganize repo, create deep-charm
This commit is contained in:
parent
d741640d28
commit
89478f679c
64 changed files with 913 additions and 744 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -2,7 +2,7 @@
|
|||
/.pesde
|
||||
/build
|
||||
/packages/**/wally.lock
|
||||
/packages/**/LICENSE.md
|
||||
/packages/**/LICENSE*
|
||||
/packages/**/README.md
|
||||
/sourcemap.json
|
||||
node_modules
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
(require)("../tests/run.server")
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
|
|
@ -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
141
README.md
|
|
@ -14,7 +14,7 @@
|
|||
|
||||

|
||||
[](https://www.npmjs.com/package/@rbxts/charm)
|
||||
[](LICENSE.md)
|
||||
[](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">
|
||||
|
||||
[](LICENSE.md)
|
||||
[](LICENSE)
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 } = {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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 })
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
packages/charm/src/index.d.ts
vendored
48
packages/charm/src/index.d.ts
vendored
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,16 +45,12 @@ 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 update: (sub: ReactiveNode) -> boolean
|
||||
local notify: (sub: ReactiveNode) -> ()
|
||||
local unwatched: (sub: ReactiveNode) -> ()
|
||||
|
||||
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
|
||||
local link = sub.depsTail
|
||||
|
|
@ -227,10 +223,22 @@ local function createReactiveSystem(props: {
|
|||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
@ -9,7 +9,6 @@ local tests = {
|
|||
"mapped",
|
||||
"observe",
|
||||
"peek",
|
||||
"reactive",
|
||||
"signal",
|
||||
"subscribe",
|
||||
"topology",
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
9
packages/deep-charm/Charm.luau
Normal file
9
packages/deep-charm/Charm.luau
Normal 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
|
||||
6
packages/deep-charm/default.project.json
Normal file
6
packages/deep-charm/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "deep-charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
27
packages/deep-charm/package.json
Normal file
27
packages/deep-charm/package.json
Normal 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
53
packages/deep-charm/src/index.d.ts
vendored
Normal 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;
|
||||
165
packages/deep-charm/src/init.luau
Normal file
165
packages/deep-charm/src/init.luau
Normal 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,
|
||||
}
|
||||
79
packages/deep-charm/test/gc.test.luau
Normal file
79
packages/deep-charm/test/gc.test.luau
Normal 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 {}
|
||||
11
packages/deep-charm/test/init.luau
Normal file
11
packages/deep-charm/test/init.luau
Normal 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 {}
|
||||
|
|
@ -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 } } })
|
||||
9
packages/deep-charm/wally.toml
Normal file
9
packages/deep-charm/wally.toml
Normal 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"
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
6
pnpm-lock.yaml
generated
|
|
@ -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
1
tests/init.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
(require)("@self/run.server")
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue