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

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