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://angular.dev/guide/signals
[Migrating from an older version?](#migration)
<details>
<summary><b>Table of Contents</b></summary>
@ -42,6 +44,7 @@
- [`signal(initialValue, equals?)`](#signalinitialvalue-equals)
- [`computed(getter)`](#computedgetter)
- [`effect(callback)`](#effectcallback)
- [`reactive(initialValue)`](#reactiveinitialvalue)
- [`untracked(callback)`](#untrackedcallback)
- [`peek(callback)`](#peekcallback)
- [`batch(callback)`](#batchcallback)
@ -54,7 +57,6 @@
- [`atom(initialValue, equals?)`](#atominitialvalue-equals)
- [`recursive()`](#recursive)
- [`trigger(callback)`](#triggercallback)
- [`reactive(initialValue)`](#reactiveinitialvalue)
- [`flags`](#flags)
- [Client-Server Sync](#client-server-sync)
- [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)`
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
local getTracked, setTracked = signal(0)
@ -294,7 +337,7 @@ setUntracked(1) -- No output
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
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`
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`.
- [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
- `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.
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
- Added [`listen()`](#listengetter-callback) to create a subscription that runs once immediately
- Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once
- Added [`onCleanup()`](#oncleanupcallback-failsilently) to bind a cleanup function to the active effect
- Added [`trigger()`](#triggercallback) to trigger updates for table mutations
- The [`computed()`](#computedgetter) callback now gets called with the previous computed value
- Optimized `computed()` to use lazy evaluation instead of eager updates
- Optimized `mapped()` to only map values that changed in the original table
- [`reactive()`](#reactiveinitialvalue): make mutable tables deeply reactive
- [`signal()`](#signalinitialvalue-equals): make reads and writes more explicit
- [`listen()`](#listengetter-callback): create a subscription that runs once immediately
- [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once
- [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect
- [`trigger()`](#triggercallback): trigger updates for table mutations
---

View file

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

View file

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

View file

@ -2,13 +2,11 @@ local Charm = require("../Charm")
local patch = require("./patch")
local types = require("./types")
type ReactiveProxy<T = {}> = Charm.ReactiveProxy<T>
type SyncPayload = types.SyncPayload
type SignalSetter<T = any> = (update: ((T) -> T) | T) -> ()
type ReactiveSetter<T = any> = (update: ((T) -> ()) | T) -> ()
type Reactive<T = any> = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter<T> }))
type SignalSetter<T = any> = (T | (T) -> T) -> ()
type ReactiveSetter<T = any> = (T | (T) -> ()) -> ()
local trackedSignals: { [string]: SignalSetter } = {}
local trackedReactives: { [string]: ReactiveSetter } = {}
@ -24,12 +22,12 @@ local trackedReactives: { [string]: ReactiveSetter } = {}
@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
if type(signal) == "function" then
trackedSignals[key] = signal
else
trackedReactives[key] = getmetatable((signal :: any) :: Reactive).setValue
trackedReactives[key] = getmetatable(signal :: ReactiveProxy).set
end
end
end

View file

@ -39,7 +39,7 @@ end
long as the target array is not sparse. This function ensures that the diff
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)
if maxn == 0 or maxn == #object then
@ -65,7 +65,7 @@ local function diff(oldState: any, newState: any): any
return newState
end
local diffs: { [any]: any } = table.clone(newState)
local diffs = table.clone(newState)
for key, oldValue in oldState do
diffs[key] = diff(oldValue, newState[key])

View file

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

View file

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

View file

@ -1,4 +1,4 @@
local function isTableUnsafe(object: { [any]: any })
local function isTableUnsafe(object: {})
local key = next(object)
if key == nil then
@ -12,7 +12,7 @@ local function isTableUnsafe(object: { [any]: any })
for key in object do
if type(key) ~= keyType then
return true
elseif keyType == "number" and key > maxKey then
elseif type(key) == "number" and key > maxKey then
maxKey = key
end
end
@ -33,7 +33,7 @@ end
@param value The table value.
@param key The key of the value.
]]
local function validate(value: { [any]: any })
local function validate(value: {})
if isTableUnsafe(value) then
warn(
`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 suite = require("../../../../test/suite")
local atom = Charm.atom
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 test = suite.test
--[[
Creates a weak reference to a value.
]]
local function weakRef<T>(value: T): { value: T? }
return setmetatable({ value = value }, { __mode = "v" }) :: any
end
--[[
Triggers garbage collection by creating a large number of tables.
]]
local function gc()
for _ = 1, 2000 do
local _ = table.create(100, 0)
@ -41,14 +38,14 @@ end
test("should release computed cache", function()
local srcRef = weakRef({})
local src = atom(srcRef.value)
local src, setSrc = signal(srcRef.value)
local c = computed(function()
return src()
end)
c() -- cache src value
src(nil) -- release value
c = unlink(c) -- release computed
setSrc(nil) -- release value
unlink(c) -- release computed
gc()
assert(srcRef.value == nil, "should release computed cache")
@ -56,7 +53,7 @@ end)
test("should not release effect for atom", function()
local effectCalls = 0
local src = atom(0)
local src, setSrc = signal(0)
effect(function()
effectCalls += 1
@ -65,8 +62,52 @@ test("should not release effect for atom", function()
assert(effectCalls == 1, "should call effect once")
gc()
src(src() + 1)
setSrc(src() + 1)
assert(effectCalls == 2, "should call effect again")
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 {}

View file

@ -3,6 +3,7 @@ local suite = require("../../../../test/suite")
local effect = Charm.effect
local reactive = Charm.reactive
local toRaw = Charm.toRaw
local test = suite.test
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, ","))
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 {}

View file

@ -302,14 +302,23 @@ export function recursive(): void;
* ```
*
* @param initialValue The value to make deeply reactive.
* @return A proxy table that tracks reads to nested properties.
* @return A function for updating the original table.
* @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]>;
/**
* 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 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
type Target = { [any]: any }
type Proxy = {
__target: Target,
__signals: { [any]: () -> any },
type ReactiveProxyMeta<T = {}> = {
value: T,
track: () -> (),
set: (T | (T) -> ()?) -> (),
}
type ReactiveProxyProps<T = {}> = {
__target: T,
__signals: { [unknown]: () -> unknown },
}
export type ReactiveProxy<T = {}> = T & typeof(setmetatable({} :: ReactiveProxyProps<T>, {} :: ReactiveProxyMeta<T>))
type EffectScopeNode = ReactiveNode & {
cleanups: { () -> () },
}
@ -135,7 +141,7 @@ local function runCleanups(effect: EffectScopeNode)
end
end
local function run(e: EffectNode): ()
local function run(e: EffectNode)
local flags = e.flags
if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then
runCleanups(e)
@ -157,7 +163,7 @@ local function run(e: EffectNode): ()
end
end
local function flush(): ()
local function flush()
while notifyIndex < queuedLength do
local effect = queued[notifyIndex] :: EffectNode
queued[notifyIndex] = nil
@ -320,6 +326,50 @@ local function effectOper(this: EffectScopeNode): () -> ()
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
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 tracked = toSignal(getter)
local current: T
return effect(function()
local previous = current
current = tracked()
@ -614,6 +665,7 @@ local function subscribe<T>(getter: () -> T, fn: (state: T, lastState: T) -> ())
local tracked = toSignal(getter)
local current: T
local firstRun = true
return effect(function()
local previous = current
current = tracked()
@ -673,6 +725,7 @@ local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) ->
end
end
end
for key, value in data do
if innerScopes[key] == nil then
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.
batch(updateScopes, getter())
end)
return function()
if disposed then
return
@ -781,16 +835,16 @@ end
@example
```luau
local users, setUsers = reactive({
user = { name = "John", surname = "Doe" },
{ name = "John", surname = "Doe" },
})
effect(function()
print(users.user and `{users.user.name} {users.user.surname}`)
print(`{users[1].name} {users[1].surname}`)
end)
setUsers(function(state)
state.user.name = "Jane"
state.user.surname = "Smith"
state[1].name = "Jane"
state[1].surname = "Smith"
end)
```
@ -801,53 +855,32 @@ end
]]
local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ())
local track = signal(nil)
local proxyMeta = { value = initialValue, track = track }
local proxyMap = setmetatable({} :: { [{}]: any }, { __mode = "v" })
local function setValue(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 :: Target)[key] = value
end
local function trackProperty(cache: { [any]: () -> any }, target: {}, key: any)
if cache[key] then
return cache[key]()
end
trigger(track)
cache[key] = computed(function()
track()
return target[key]
end)
return cache[key]()
end
local proxyMeta = {
initialValue = initialValue,
track = track,
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)
local function createProxy<T>(target: T & {}): ReactiveProxy<T>
if proxyMap[target] then
return proxyMap[target]
end
proxyMap[target] = setmetatable({
__target = target,
__signals = setmetatable({}, { __mode = "v" }),
}, proxyMeta)
return proxyMap[target]
end
local function nextProxy(target: Target, index: 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
function proxyMeta.__index(self: ReactiveProxy, key: any): any
if activeSub then
trackProperty(self.__signals, self.__target, key)
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
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
value = value.__target
end
@ -865,61 +898,61 @@ local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -
end
end
function proxyMeta.__iter(self: Proxy)
function proxyMeta.__iter(self: ReactiveProxy)
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
function proxyMeta.__len(self: Proxy)
function proxyMeta.__len(self: ReactiveProxy)
track()
return #self.__target
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
local reactiveSystem = system.createReactiveSystem({
update = function(node)
if node.depsTail then
return updateComputed(node :: ComputedNode)
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,
update = update,
notify = notify,
unwatched = unwatched,
})
link = reactiveSystem.link
@ -954,4 +987,5 @@ return {
mapper: (item: VI, key: KI) -> (VO, KO)
) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }),
reactive = reactive,
toRaw = toRaw,
}

View file

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