feat: add toRaw()

This commit is contained in:
littensy 2025-11-19 21:38:56 -08:00
parent f082956ed9
commit 9f66803f04
13 changed files with 297 additions and 198 deletions

104
README.md
View file

@ -34,6 +34,8 @@
- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
- https://angular.dev/guide/signals - https://angular.dev/guide/signals
[Migrating from an older version?](#migration)
<details> <details>
<summary><b>Table of Contents</b></summary> <summary><b>Table of Contents</b></summary>
@ -42,6 +44,7 @@
- [`signal(initialValue, equals?)`](#signalinitialvalue-equals) - [`signal(initialValue, equals?)`](#signalinitialvalue-equals)
- [`computed(getter)`](#computedgetter) - [`computed(getter)`](#computedgetter)
- [`effect(callback)`](#effectcallback) - [`effect(callback)`](#effectcallback)
- [`reactive(initialValue)`](#reactiveinitialvalue)
- [`untracked(callback)`](#untrackedcallback) - [`untracked(callback)`](#untrackedcallback)
- [`peek(callback)`](#peekcallback) - [`peek(callback)`](#peekcallback)
- [`batch(callback)`](#batchcallback) - [`batch(callback)`](#batchcallback)
@ -54,7 +57,6 @@
- [`atom(initialValue, equals?)`](#atominitialvalue-equals) - [`atom(initialValue, equals?)`](#atominitialvalue-equals)
- [`recursive()`](#recursive) - [`recursive()`](#recursive)
- [`trigger(callback)`](#triggercallback) - [`trigger(callback)`](#triggercallback)
- [`reactive(initialValue)`](#reactiveinitialvalue)
- [`flags`](#flags) - [`flags`](#flags)
- [Client-Server Sync](#client-server-sync) - [Client-Server Sync](#client-server-sync)
- [Installation](#installation-1) - [Installation](#installation-1)
@ -276,9 +278,50 @@ setCounter(2) -- Outer: 2, Inner: 2
--- ---
### `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" },
})
-- Output: John Doe
effect(function()
print(`{users[1].name} {users[1].surname}`)
end)
-- Output: Jane Smith
setUsers(function(state)
state[1].name = "Jane"
state[1].surname = "Smith"
table.insert(state, { name = "Steve", surname = "Doe" })
end)
-- 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
```
---
### `untracked(callback)` ### `untracked(callback)`
In case you want to read signals but don't want to subscribe to them, you can use `untracked()` to essentially run code _outside_ the current effect, preventing signals and effects in the callback from being tracked. In case you want to opt-out of dependency tracking in an effect, you can use `untracked()` to call a function _outside_ the current scope, preventing signals and effects in the function from being tracked.
```luau ```luau
local getTracked, setTracked = signal(0) local getTracked, setTracked = signal(0)
@ -294,7 +337,7 @@ setUntracked(1) -- No output
setTracked(2) -- Tracked: 2, Untracked: 1 setTracked(2) -- Tracked: 2, Untracked: 1
``` ```
Because `untracked()` executes the callback outside the current effect, nested effects created in the callback do not get tracked by the parent effect: Because `untracked()` executes the callback outside the current effect, nested effects created during the execution of the callback will not be tracked by the parent effect:
```luau ```luau
local stopEffect local stopEffect
@ -602,45 +645,6 @@ print(total()) -- 2
--- ---
### `reactive(initialValue)`
The `reactive()` function creates a mutable reactive object that subscribes to only the properties you read. It also returns a setter function you can use to mutate the original table:
```luau
local users, setUsers = reactive({
user = { name = "John", surname = "Doe" },
})
-- Output: John Doe
effect(function()
print(`{users.user.name} {users.user.surname}`)
end)
-- Output: Jane Smith
setUsers(function(state)
state.user.name = "Jane"
state.user.surname = "Smith"
end)
```
You can also set properties on the reactive object directly:
```luau
local id = reactive({ name = "John", surname = "Doe" })
effect(function()
print(`{id.name} {id.surname}`)
end)
id.name = "Jane" -- Jane Doe
id.surname = "Smith" -- Jane Smith
```
> [!WARNING]
> Be careful when mutating the reactive object! Because it's a proxy, functions like `table.insert` will not work. Table functions should be called on the original table, which is provided by the setter function.
---
### `flags` ### `flags`
Charm exposes the following global flags to customize behavior: Charm exposes the following global flags to customize behavior:
@ -948,7 +952,7 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
- Instead of creating client/server syncers, these modules now act like singletons. Sync APIs are called directly through `CharmSync.client`/`server`. - Instead of creating client/server syncers, these modules now act like singletons. Sync APIs are called directly through `CharmSync.client`/`server`.
- [Read the updated docs for syncing state →](#client-server-sync) - [Read the updated docs for syncing state →](#client-server-sync)
3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors if you didn't use the old `__DEV__` flag. The flags have the following behavior: 3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors. The flags have the following behaviors:
- `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error - `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error
- `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations - `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations
@ -966,14 +970,12 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
- Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback. - Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback.
6. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! 6. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)!
- Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit - [`reactive()`](#reactiveinitialvalue): make mutable tables deeply reactive
- Added [`listen()`](#listengetter-callback) to create a subscription that runs once immediately - [`signal()`](#signalinitialvalue-equals): make reads and writes more explicit
- Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once - [`listen()`](#listengetter-callback): create a subscription that runs once immediately
- Added [`onCleanup()`](#oncleanupcallback-failsilently) to bind a cleanup function to the active effect - [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once
- Added [`trigger()`](#triggercallback) to trigger updates for table mutations - [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect
- The [`computed()`](#computedgetter) callback now gets called with the previous computed value - [`trigger()`](#triggercallback): trigger updates for table mutations
- Optimized `computed()` to use lazy evaluation instead of eager updates
- Optimized `mapped()` to only map values that changed in the original table
--- ---

View file

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

View file

@ -5,7 +5,7 @@ local suite = require("../../../../test/suite")
local test = suite.test local test = suite.test
local afterEach = suite.afterEach local afterEach = suite.afterEach
local function mockArraySerdes(object: { [any]: any }): { [any]: any } local function mockArraySerdes(object: {}): {}
if object[1] then if object[1] then
local copy = {} local copy = {}
for index = 1, #object do for index = 1, #object do

View file

@ -2,13 +2,11 @@ local Charm = require("../Charm")
local patch = require("./patch") local patch = require("./patch")
local types = require("./types") local types = require("./types")
type ReactiveProxy<T = {}> = Charm.ReactiveProxy<T>
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
type SignalSetter<T = any> = (update: ((T) -> T) | T) -> () type SignalSetter<T = any> = (T | (T) -> T) -> ()
type ReactiveSetter<T = any> = (T | (T) -> ()) -> ()
type ReactiveSetter<T = any> = (update: ((T) -> ()) | T) -> ()
type Reactive<T = any> = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter<T> }))
local trackedSignals: { [string]: SignalSetter } = {} local trackedSignals: { [string]: SignalSetter } = {}
local trackedReactives: { [string]: ReactiveSetter } = {} local trackedReactives: { [string]: ReactiveSetter } = {}
@ -24,12 +22,12 @@ local trackedReactives: { [string]: ReactiveSetter } = {}
@param signals A map of setter functions to sync with the server. @param signals A map of setter functions to sync with the server.
]] ]]
local function addSignals(signals: { [string]: SignalSetter | { [any]: any } }) local function addSignals(signals: { [string]: SignalSetter | {} })
for key, signal in signals do for key, signal in signals do
if type(signal) == "function" then if type(signal) == "function" then
trackedSignals[key] = signal trackedSignals[key] = signal
else else
trackedReactives[key] = getmetatable((signal :: any) :: Reactive).setValue trackedReactives[key] = getmetatable(signal :: ReactiveProxy).set
end end
end end
end end

View file

@ -39,7 +39,7 @@ end
long as the target array is not sparse. This function ensures that the diff long as the target array is not sparse. This function ensures that the diff
function produces a patch that can be correctly applied. function produces a patch that can be correctly applied.
]] ]]
local function stringifySparseArray(object: { any }): { [string | number]: any } local function stringifySparseArray(object: {}): { [string | number]: any }
local maxn = table.maxn(object) local maxn = table.maxn(object)
if maxn == 0 or maxn == #object then if maxn == 0 or maxn == #object then
@ -65,7 +65,7 @@ local function diff(oldState: any, newState: any): any
return newState return newState
end end
local diffs: { [any]: any } = table.clone(newState) local diffs = table.clone(newState)
for key, oldValue in oldState do for key, oldValue in oldState do
diffs[key] = diff(oldValue, newState[key]) diffs[key] = diff(oldValue, newState[key])

View file

@ -4,20 +4,19 @@ local patch = require("./patch")
local types = require("./types") local types = require("./types")
local utils = require("./utils") local utils = require("./utils")
type ReactiveProxy<T = {}> = Charm.ReactiveProxy<T>
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
type SyncStatus = number type SyncStatus = number
type ClientKeys = { [string]: SyncStatus } type ClientKeys = { [string]: SyncStatus }
type Reactive<T> = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () }))
type TrackedState<T = any> = { type TrackedState<T = any> = {
target: any, target: any,
currentValue: T, currentValue: T,
lastSyncedValue: T, lastSyncedValue: T,
dispose: () -> (),
isReactive: boolean?, isReactive: boolean?,
dispose: () -> (),
} }
type StateUpdate = { type StateUpdate = {
@ -68,21 +67,21 @@ local function trackSignal<T>(key: string, getter: () -> T)
trackedStates[key] = trackedState trackedStates[key] = trackedState
end end
local function trackReactive<T>(key: string, reactive: Reactive<T>) local function trackReactive<T>(key: string, proxy: ReactiveProxy<T>)
if trackedStates[key] then if trackedStates[key] then
if trackedStates[key].target ~= reactive then if trackedStates[key].target ~= proxy then
warn("[Charm] Attempted to sync different states with the same key:", key) warn("[Charm] Attempted to sync different states with the same key:", key)
end end
return return
end end
local meta = getmetatable(reactive) local meta = getmetatable(proxy)
local snapshot = utils.deepCopy(meta.initialValue) local snapshot = utils.deepCopy(meta.value)
local firstRun = true local firstRun = true
local trackedState = { local trackedState = {
target = reactive, target = proxy,
currentValue = meta.initialValue, currentValue = meta.value,
lastSyncedValue = snapshot, lastSyncedValue = snapshot,
isReactive = true, isReactive = true,
} }
@ -93,7 +92,7 @@ local function trackReactive<T>(key: string, reactive: Reactive<T>)
firstRun = false firstRun = false
else else
local oldSnapshot = snapshot local oldSnapshot = snapshot
snapshot = utils.deepCopy(meta.initialValue) snapshot = utils.deepCopy(meta.value)
table.insert(globalUpdates, { table.insert(globalUpdates, {
key = key, key = key,
newValue = snapshot, newValue = snapshot,
@ -258,7 +257,7 @@ end
@param client The client receiving state updates. @param client The client receiving state updates.
@param signals A map of getter functions to sync with the client. @param signals A map of getter functions to sync with the client.
]] ]]
local function addSignalsToClient(client: any, signals: { [string]: { [any]: any } | () -> any }) local function addSignalsToClient(client: any, signals: { [string]: {} | () -> any })
if not clients[client] then if not clients[client] then
clients[client] = {} clients[client] = {}
end end

View file

@ -6,10 +6,6 @@ type NestedTable = {
[any]: NestedTable | any, [any]: NestedTable | any,
} }
type FlattenedTable = {
[any]: any,
}
--[[ --[[
Flattens a nested table into a single table with slash-separated keys. Flattens a nested table into a single table with slash-separated keys.
Useful for recursively collecting atoms returned by modules. Useful for recursively collecting atoms returned by modules.
@ -17,15 +13,15 @@ type FlattenedTable = {
@param input A nested table. @param input A nested table.
@return A flattened table with slash-separated keys. @return A flattened table with slash-separated keys.
]] ]]
local function flatten(input: NestedTable): FlattenedTable local function flatten<Flattened>(input: {}): Flattened
local result: FlattenedTable = {} local result = {}
local function visit(node: NestedTable, path: string) local function visit(node: {}, path: string)
for key, value in node do for key, value in node do
local location = if path == "" then key else path .. "/" .. key local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
if type(value) == "table" then if type(value) == "table" then
visit(value :: NestedTable, location) visit(value :: {}, location)
else else
result[location] = value result[location] = value
end end
@ -34,7 +30,7 @@ local function flatten(input: NestedTable): FlattenedTable
visit(input, "") visit(input, "")
return result return result :: any
end end
--[[ --[[

View file

@ -1,4 +1,4 @@
local function isTableUnsafe(object: { [any]: any }) local function isTableUnsafe(object: {})
local key = next(object) local key = next(object)
if key == nil then if key == nil then
@ -12,7 +12,7 @@ local function isTableUnsafe(object: { [any]: any })
for key in object do for key in object do
if type(key) ~= keyType then if type(key) ~= keyType then
return true return true
elseif keyType == "number" and key > maxKey then elseif type(key) == "number" and key > maxKey then
maxKey = key maxKey = key
end end
end end
@ -33,7 +33,7 @@ end
@param value The table value. @param value The table value.
@param key The key of the value. @param key The key of the value.
]] ]]
local function validate(value: { [any]: any }) local function validate(value: {})
if isTableUnsafe(value) then if isTableUnsafe(value) then
warn( warn(
`Cannot sync tables unsupported by remote events! See the table below for more info.\n\n` `Cannot sync tables unsupported by remote events! See the table below for more info.\n\n`

View file

@ -1,22 +1,19 @@
local Charm = require("../") local Charm = require("../")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local atom = Charm.atom type ReactiveProxy<T = any> = Charm.ReactiveProxy<T>
local signal = Charm.signal
local computed = Charm.computed local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local effectScope = Charm.effectScope local effectScope = Charm.effectScope
local reactive = Charm.reactive
local test = suite.test local test = suite.test
--[[
Creates a weak reference to a value.
]]
local function weakRef<T>(value: T): { value: T? } local function weakRef<T>(value: T): { value: T? }
return setmetatable({ value = value }, { __mode = "v" }) :: any return setmetatable({ value = value }, { __mode = "v" }) :: any
end end
--[[
Triggers garbage collection by creating a large number of tables.
]]
local function gc() local function gc()
for _ = 1, 2000 do for _ = 1, 2000 do
local _ = table.create(100, 0) local _ = table.create(100, 0)
@ -41,14 +38,14 @@ end
test("should release computed cache", function() test("should release computed cache", function()
local srcRef = weakRef({}) local srcRef = weakRef({})
local src = atom(srcRef.value) local src, setSrc = signal(srcRef.value)
local c = computed(function() local c = computed(function()
return src() return src()
end) end)
c() -- cache src value c() -- cache src value
src(nil) -- release value setSrc(nil) -- release value
c = unlink(c) -- release computed unlink(c) -- release computed
gc() gc()
assert(srcRef.value == nil, "should release computed cache") assert(srcRef.value == nil, "should release computed cache")
@ -56,7 +53,7 @@ end)
test("should not release effect for atom", function() test("should not release effect for atom", function()
local effectCalls = 0 local effectCalls = 0
local src = atom(0) local src, setSrc = signal(0)
effect(function() effect(function()
effectCalls += 1 effectCalls += 1
@ -65,8 +62,52 @@ test("should not release effect for atom", function()
assert(effectCalls == 1, "should call effect once") assert(effectCalls == 1, "should call effect once")
gc() gc()
src(src() + 1) setSrc(src() + 1)
assert(effectCalls == 2, "should call effect again") assert(effectCalls == 2, "should call effect again")
end) end)
test("should release reactive tables", function()
local valueRef = weakRef({})
local src = reactive({ value = valueRef.value })
effect(function()
local _ = src.value -- create proxy
end)
src.value = nil :: any -- release proxy
gc()
assert(valueRef.value == nil, "should release reactive table")
assert((src :: ReactiveProxy).__signals.value == nil, "should release reactive signals")
end)
test("should release reactive property deps", function()
local src = reactive({ foo = 1 })
local c = computed(function()
return src.foo
end)
c()
assert((src :: ReactiveProxy).__signals.foo ~= nil, "should have deps for foo")
c = nil :: any
gc()
assert((src :: ReactiveProxy).__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)
return {} return {}

View file

@ -3,6 +3,7 @@ local suite = require("../../../../test/suite")
local effect = Charm.effect local effect = Charm.effect
local reactive = Charm.reactive local reactive = Charm.reactive
local toRaw = Charm.toRaw
local test = suite.test local test = suite.test
test("should track nested properties", function() test("should track nested properties", function()
@ -121,4 +122,22 @@ test("should overwrite with table passed to setter", function()
assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ",")) assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ","))
end) end)
test("should unwrap raw table", function()
local raw = {}
local src = reactive(raw)
local runs = 0
effect(function()
local _ = toRaw(src)
runs += 1
end)
assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(toRaw(raw) == raw, "unwrap modified a non-proxy value")
assert(runs == 1, runs)
src.x = 1
assert(runs == 2, runs)
end)
return {} return {}

View file

@ -302,14 +302,23 @@ export function recursive(): void;
* ``` * ```
* *
* @param initialValue The value to make deeply reactive. * @param initialValue The value to make deeply reactive.
* @return A proxy table that tracks reads to nested properties. * @returns A proxy table and a function to mutate the original table.
* @return A function for updating the original table.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/ */
export function reactive<T extends object>( export function reactive<T extends object>(
initialValue: T, initialValue: T,
): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>; ): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>;
/**
* 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;
export function getActiveSub(): ReactiveNode | undefined; export function getActiveSub(): ReactiveNode | undefined;
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;

View file

@ -6,13 +6,19 @@ export type Link = system.Link
export type Atom<T = any> = (...((T) -> T) | T) -> T export type Atom<T = any> = (...((T) -> T) | T) -> T
type Target = { [any]: any } type ReactiveProxyMeta<T = {}> = {
value: T,
type Proxy = { track: () -> (),
__target: Target, set: (T | (T) -> ()?) -> (),
__signals: { [any]: () -> any },
} }
type ReactiveProxyProps<T = {}> = {
__target: T,
__signals: { [unknown]: () -> unknown },
}
export type ReactiveProxy<T = {}> = T & typeof(setmetatable({} :: ReactiveProxyProps<T>, {} :: ReactiveProxyMeta<T>))
type EffectScopeNode = ReactiveNode & { type EffectScopeNode = ReactiveNode & {
cleanups: { () -> () }, cleanups: { () -> () },
} }
@ -135,7 +141,7 @@ local function runCleanups(effect: EffectScopeNode)
end end
end end
local function run(e: EffectNode): () local function run(e: EffectNode)
local flags = e.flags local flags = e.flags
if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then
runCleanups(e) runCleanups(e)
@ -157,7 +163,7 @@ local function run(e: EffectNode): ()
end end
end end
local function flush(): () local function flush()
while notifyIndex < queuedLength do while notifyIndex < queuedLength do
local effect = queued[notifyIndex] :: EffectNode local effect = queued[notifyIndex] :: EffectNode
queued[notifyIndex] = nil queued[notifyIndex] = nil
@ -320,6 +326,50 @@ local function effectOper(this: EffectScopeNode): () -> ()
end end
end end
local function update(node: ReactiveNode): boolean
if node.depsTail then
return updateComputed(node :: ComputedNode)
else
return updateSignal(node :: SignalNode)
end
end
local function notify(effect: ReactiveNode)
local insertIndex = queuedLength
local firstInsertedIndex = insertIndex
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
insertIndex -= 1
while firstInsertedIndex < insertIndex do
local left = queued[firstInsertedIndex]
queued[firstInsertedIndex] = queued[insertIndex]
queued[insertIndex] = left
firstInsertedIndex += 1
insertIndex -= 1
end
end
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 Creates a reactive signal that stores a value and notifies subscribers when
the value changes. The signal provides both a getter and a setter. the value changes. The signal provides both a getter and a setter.
@ -590,6 +640,7 @@ end
local function listen<T>(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () local function listen<T>(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> ()
local tracked = toSignal(getter) local tracked = toSignal(getter)
local current: T local current: T
return effect(function() return effect(function()
local previous = current local previous = current
current = tracked() current = tracked()
@ -614,6 +665,7 @@ local function subscribe<T>(getter: () -> T, fn: (state: T, lastState: T) -> ())
local tracked = toSignal(getter) local tracked = toSignal(getter)
local current: T local current: T
local firstRun = true local firstRun = true
return effect(function() return effect(function()
local previous = current local previous = current
current = tracked() current = tracked()
@ -673,6 +725,7 @@ local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) ->
end end
end end
end end
for key, value in data do for key, value in data do
if innerScopes[key] == nil then if innerScopes[key] == nil then
local disposeScope = effectScope(function() local disposeScope = effectScope(function()
@ -696,6 +749,7 @@ local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) ->
-- interfere with the current update cycle. -- interfere with the current update cycle.
batch(updateScopes, getter()) batch(updateScopes, getter())
end) end)
return function() return function()
if disposed then if disposed then
return return
@ -781,16 +835,16 @@ end
@example @example
```luau ```luau
local users, setUsers = reactive({ local users, setUsers = reactive({
user = { name = "John", surname = "Doe" }, { name = "John", surname = "Doe" },
}) })
effect(function() effect(function()
print(users.user and `{users.user.name} {users.user.surname}`) print(`{users[1].name} {users[1].surname}`)
end) end)
setUsers(function(state) setUsers(function(state)
state.user.name = "Jane" state[1].name = "Jane"
state.user.surname = "Smith" state[1].surname = "Smith"
end) end)
``` ```
@ -801,53 +855,32 @@ end
]] ]]
local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ()) local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ())
local track = signal(nil) local track = signal(nil)
local proxyMeta = { value = initialValue, track = track }
local proxyMap = setmetatable({} :: { [{}]: any }, { __mode = "v" })
local function setValue(update: ((T) -> ()) | T?) local function trackProperty(cache: { [any]: () -> any }, target: {}, key: any)
if type(update) == "function" then if cache[key] then
update(initialValue) return cache[key]()
elseif type(update) == "table" then
table.clear(initialValue)
for key, value in update do
(initialValue :: Target)[key] = value
end
end end
trigger(track) cache[key] = computed(function()
track()
return target[key]
end)
return cache[key]()
end end
local proxyMeta = { local function createProxy<T>(target: T & {}): ReactiveProxy<T>
initialValue = initialValue, if proxyMap[target] then
track = track, return proxyMap[target]
setValue = setValue,
}
local proxyMap = setmetatable({} :: { [Target]: any }, { __mode = "k" })
local function trackProperty(cache: { [any]: () -> any }, target: Target, key: any)
if not cache[key] then
cache[key] = untracked(computed, function()
track()
return target[key]
end)
end
cache[key]()
end
local function createProxy(target: Target): Proxy
if not proxyMap[target] then
proxyMap[target] = setmetatable({
__target = target,
__signals = setmetatable({}, { __mode = "v" }),
}, proxyMeta)
end end
proxyMap[target] = setmetatable({
__target = target,
__signals = setmetatable({}, { __mode = "v" }),
}, proxyMeta)
return proxyMap[target] return proxyMap[target]
end end
local function nextProxy(target: Target, index: any) function proxyMeta.__index(self: ReactiveProxy, key: any): any
local nextKey, nextValue = next(target, index)
return nextKey, if type(nextValue) == "table" then createProxy(nextValue) else nextValue
end
function proxyMeta.__index(self: Proxy, key: any): any
if activeSub then if activeSub then
trackProperty(self.__signals, self.__target, key) trackProperty(self.__signals, self.__target, key)
end end
@ -855,7 +888,7 @@ local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -
return if type(value) == "table" then createProxy(value) else value return if type(value) == "table" then createProxy(value) else value
end end
function proxyMeta.__newindex(self: Proxy, key: any, value: any) function proxyMeta.__newindex(self: ReactiveProxy, key: any, value: any)
if type(value) == "table" and getmetatable(value) == proxyMeta then if type(value) == "table" and getmetatable(value) == proxyMeta then
value = value.__target value = value.__target
end end
@ -865,61 +898,61 @@ local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -
end end
end end
function proxyMeta.__iter(self: Proxy) function proxyMeta.__iter(self: ReactiveProxy)
track() track()
return nextProxy, self.__target local function iterator(target: {}, index: any)
local key, value = next(target, index)
return key, if type(value) == "table" then createProxy(value) else value
end
return iterator, self.__target
end end
function proxyMeta.__len(self: Proxy) function proxyMeta.__len(self: ReactiveProxy)
track() track()
return #self.__target return #self.__target
end end
return createProxy(initialValue) :: any, setValue function proxyMeta.set(update: ((T) -> ()) | T?)
if type(update) == "function" then
update(initialValue)
elseif type(update) == "table" then
table.clear(initialValue)
for key, value in update :: {} do
(initialValue :: {})[key] = value
end
end
trigger(track)
end
return createProxy(initialValue), proxyMeta.set
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 unwrapped target value.
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function toRaw<T>(value: T): T
if type(value) == "table" and getmetatable(value) ~= nil then
local proxy: ReactiveProxy = value
local meta = getmetatable(proxy)
if meta.value == proxy.__target then
-- Track the root proxy since nested proxies are already tracked
meta.track()
end
return proxy.__target :: any
else
return value
end
end end
local reactiveSystem = system.createReactiveSystem({ local reactiveSystem = system.createReactiveSystem({
update = function(node) update = update,
if node.depsTail then notify = notify,
return updateComputed(node :: ComputedNode) unwatched = unwatched,
else
return updateSignal(node :: SignalNode)
end
end,
notify = function(effect)
local insertIndex = queuedLength
local firstInsertedIndex = insertIndex
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
insertIndex -= 1
while firstInsertedIndex < insertIndex do
local left = queued[firstInsertedIndex]
queued[firstInsertedIndex] = queued[insertIndex]
queued[insertIndex] = left
firstInsertedIndex += 1
insertIndex -= 1
end
end,
unwatched = function(node)
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,
}) })
link = reactiveSystem.link link = reactiveSystem.link
@ -954,4 +987,5 @@ return {
mapper: (item: VI, key: KI) -> (VO, KO) mapper: (item: VI, key: KI) -> (VO, KO)
) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), ) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }),
reactive = reactive, reactive = reactive,
toRaw = toRaw,
} }

View file

@ -89,7 +89,7 @@ local function createReactiveSystem(props: {
return false return false
end end
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number): () local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
-- DEVIATION: Do not link signals to peeking subscribers -- DEVIATION: Do not link signals to peeking subscribers
if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then
return return
@ -166,7 +166,7 @@ local function createReactiveSystem(props: {
return nextDep return nextDep
end end
local function propagate(link: Link): () local function propagate(link: Link)
repeat repeat
local sub = link.sub local sub = link.sub
local flags = sub.flags local flags = sub.flags
@ -200,7 +200,7 @@ local function createReactiveSystem(props: {
until not link until not link
end end
local function shallowPropagate(link: Link): () local function shallowPropagate(link: Link)
repeat repeat
local sub = link.sub local sub = link.sub
local flags = sub.flags local flags = sub.flags