fix: refine reactive proxy logic

This commit is contained in:
littensy 2025-11-28 01:18:57 -08:00
parent b3a76face9
commit b826401bf3
6 changed files with 145 additions and 102 deletions

View file

@ -102,6 +102,22 @@ test("should replicate partial state patches (reactive)", function()
assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'") assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'")
end) end)
test("should handle nested proxies", function()
local client = {}
local src = Charm.reactive({ (Charm.reactive({})) })
local payloads: {}? = nil
server.addSignalsToClient(client, { src = src })
server.connect(function(_, newPayloads)
payloads = newPayloads
end)
server.flush()
payloads = nil
server.flush()
assert(payloads == nil, "Expected no payloads to be sent for unchanged nested proxy")
end)
test("should init new clients", function() test("should init new clients", function()
local clientA = {} local clientA = {}
local clientB = {} local clientB = {}

View file

@ -26,7 +26,7 @@ local function addSignals(signals: { [string]: Setter | {} })
if type(signal) == "function" then if type(signal) == "function" then
trackedSignals[key] = signal trackedSignals[key] = signal
else else
trackedReactives[key] = getmetatable(signal :: ReactiveProxy).set trackedReactives[key] = ((signal :: any) :: ReactiveProxy).__set
end end
end end
end end

View file

@ -33,7 +33,7 @@ local globalUpdates: { StateUpdate } = {}
local clients: { [any]: ClientKeys } = {} local clients: { [any]: ClientKeys } = {}
local connection: RBXScriptConnection? local connection: RBXScriptConnection?
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown) local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
if trackedStates[key] then if trackedStates[key] then
@ -47,8 +47,7 @@ local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
local trackedState: TrackedState<T> local trackedState: TrackedState<T>
local initialValue = getter() local initialValue = getter()
local rawInitialValue = Charm.toRaw(initialValue) local rawInitialValue = Charm.toRaw(initialValue)
local initialIsReactive = initialValue ~= rawInitialValue local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
local snapshot = if initialIsReactive then utils.deepCopy(rawInitialValue) else initialValue
local dispose = Charm.effect(function() local dispose = Charm.effect(function()
local newValue = getter() local newValue = getter()
@ -62,12 +61,12 @@ local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
-- When preserving history, we need to keep track of previous values -- When preserving history, we need to keep track of previous values
-- so we can create granular patches for each change. -- so we can create granular patches for each change.
if config.preserveHistory then if config.preserveHistory then
local oldSnapshot = snapshot local prevSnapshot = snapshot
snapshot = if isReactive then utils.deepCopy(rawValue) else newValue snapshot = if isReactive then utils.deepCopy(rawValue) else newValue
table.insert(globalUpdates, { table.insert(globalUpdates, {
key = key, key = key,
newValue = snapshot, newValue = snapshot,
oldValue = oldSnapshot, oldValue = prevSnapshot,
}) })
end end
@ -79,7 +78,7 @@ local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
identifier = identifier, identifier = identifier,
currentValue = rawInitialValue, currentValue = rawInitialValue,
lastSyncedValue = snapshot, lastSyncedValue = snapshot,
isReactive = initialIsReactive, isReactive = initialValue ~= rawInitialValue,
dispose = dispose, dispose = dispose,
} }

View file

@ -49,12 +49,13 @@ local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T)
end end
--[[ --[[
Returns a deep copy of the given table. Returns a deep copy of the given table. Skips tables with metatables to
prevent copying reactive proxies.
]] ]]
local function deepCopy<T>(object: T): T local function deepCopy<T>(object: T): T
local copy: any = table.clone(object :: any) local copy: any = table.clone(object :: any)
for key, value in copy do for key, value in copy do
if type(value) == "table" then if type(value) == "table" and getmetatable(value) == nil then
copy[key] = deepCopy(value) copy[key] = deepCopy(value)
end end
end end

View file

@ -122,7 +122,7 @@ 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() test("should unwrap proxy", function()
local raw = {} local raw = {}
local src = reactive(raw) local src = reactive(raw)
local runs = 0 local runs = 0
@ -140,4 +140,25 @@ test("should unwrap raw table", function()
assert(runs == 2, runs) assert(runs == 2, runs)
end) end)
test("should unwrap nested proxy", function()
local raw = {}
local inner = reactive(raw)
local src = reactive(inner)
local runs = 0
effect(function()
local _ = toRaw(src)
runs += 1
end)
assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(runs == 1, runs)
inner.x = 1
assert(runs == 2, runs)
src.y = 2
assert(runs == 3, runs)
end)
return {} return {}

View file

@ -6,18 +6,15 @@ export type Link = system.Link
export type Atom<T = any> = (...((T) -> T) | T) -> T export type Atom<T = any> = (...((T) -> T) | T) -> T
type ReactiveProxyApi<T = {}> = { export type ReactiveProxy<T = any> = typeof(setmetatable(
value: T, {} :: {
track: () -> (), __track: () -> (),
set: (T | (T) -> ()?) -> (), __set: (T | ((T) -> ())?) -> (),
} __target: T,
__signals: { [unknown]: () -> unknown },
type ReactiveProxyProps<T = {}> = { },
__target: T, {}
__signals: { [unknown]: () -> unknown }, ))
}
export type ReactiveProxy<T = {}> = T & typeof(setmetatable({} :: ReactiveProxyProps<T>, {} :: ReactiveProxyApi<T>))
type EffectScopeNode = ReactiveNode & { type EffectScopeNode = ReactiveNode & {
cleanups: { () -> () }, cleanups: { () -> () },
@ -83,6 +80,9 @@ local queued: { EffectNode? } = {}
local queuedLength = 0 local queuedLength = 0
local activeSub: ReactiveNode? local activeSub: ReactiveNode?
local ReactiveProxy = {}
local proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" })
local function noYield<Args..., Results...>(fn: (Args...) -> Results...): (Args...) -> Results... local function noYield<Args..., Results...>(fn: (Args...) -> Results...): (Args...) -> Results...
if not flags.strict then if not flags.strict then
return fn return fn
@ -827,6 +827,34 @@ local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item:
end) end)
end end
--[[
Determines if the provided value is a reactive proxy created by `reactive()`.
@param value A value to test.
@return `true` if the value is a reactive proxy, `false` otherwise.
@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 unwrapped target value.
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function toRaw<T>(value: T): T
if isReactive(value) then
local value: ReactiveProxy<T> = value :: any
value.__track()
return toRaw(value.__target)
end
return value
end
--[[ --[[
Creates a deep reactive value that tracks properties when they're accessed. Creates a deep reactive value that tracks properties when they're accessed.
Returns a proxy object that subscribes to changes on any nested property Returns a proxy object that subscribes to changes on any nested property
@ -853,101 +881,79 @@ end
@return A function for updating 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
]] ]]
local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ()) local function reactive<T>(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: (T) -> () | T?) -> ())
local track = signal(nil) initialValue = toRaw(initialValue)
local proxyApi = { value = initialValue, track = track }
local proxyMap = setmetatable({} :: { [{}]: any }, { __mode = "v" })
local function trackProperty(cache: { [any]: () -> any }, target: {}, key: any) local proxy = proxyMap[initialValue]
if cache[key] then
return cache[key]() if not proxy then
end local track = computed(function()
cache[key] = computed(function() if trackParent then
track() trackParent()
return target[key] end
return {}
end) end)
return cache[key]()
end
local function createProxy<T>(target: T & {}): ReactiveProxy<T> local function set(update: (T) -> () | T?)
if proxyMap[target] then if type(update) == "function" then
return proxyMap[target] update(initialValue)
end elseif type(update) == "table" then
proxyMap[target] = setmetatable({ table.clear(initialValue)
__target = target, for key, value in update :: {} do
__signals = setmetatable({}, { __mode = "v" }), (initialValue :: {})[key] = value
}, proxyApi) end
return proxyMap[target] end
end
function proxyApi.__index(self: ReactiveProxy, key: any): any
if activeSub then
trackProperty(self.__signals, self.__target, key)
end
local value = self.__target[key]
return if type(value) == "table" then createProxy(value) else value
end
function proxyApi.__newindex(self: ReactiveProxy, key: any, value: any)
if type(value) == "table" and type(value.__target) == "table" then
value = value.__target
end
if self.__target[key] ~= value then
self.__target[key] = value
trigger(track) trigger(track)
end end
proxy = setmetatable({
__target = initialValue,
__signals = setmetatable({}, { __mode = "v" }) :: any,
__track = track,
__set = set,
}, ReactiveProxy)
proxyMap[initialValue] = proxy
end end
function proxyApi.__iter(self: ReactiveProxy) return proxy :: any, proxy.__set
track() end
return next, self.__target
end
function proxyApi.__len(self: ReactiveProxy) function ReactiveProxy.__index(self: ReactiveProxy, key: any): any
track() local track = self.__track
return #self.__target local signals = self.__signals
end local target = self.__target
local value = target[key]
function proxyApi.set(update: ((T) -> ()) | T?) if activeSub then
if type(update) == "function" then if not signals[key] then
update(initialValue) signals[key] = computed(function()
elseif type(update) == "table" then track()
table.clear(initialValue) return target[key]
for key, value in update :: {} do end)
(initialValue :: {})[key] = value
end
end end
trigger(track) signals[key]()
end end
return createProxy(initialValue), proxyApi.set return if type(value) == "table" then reactive(value, track) else value
end end
--[[ function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any)
Determines if the provided value is a reactive proxy created by `reactive()`. value = toRaw(value)
if self.__target[key] ~= value then
@param value A value to test. self.__target[key] = value
@return `true` if the value is a reactive proxy, `false` otherwise. trigger(self.__track)
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function isReactive(value: any): boolean
return type(value) == "table" and type(value.__target) == "table" and getmetatable(value) ~= nil
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 isReactive(value) then
getmetatable(value :: ReactiveProxy<T>).track()
return (value :: ReactiveProxy<T>).__target
end end
return value end
function ReactiveProxy.__iter(self: ReactiveProxy)
self.__track()
return next, self.__target
end
function ReactiveProxy.__len(self: ReactiveProxy)
self.__track()
return #self.__target
end end
local reactiveSystem = system.createReactiveSystem({ local reactiveSystem = system.createReactiveSystem({