mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
fix: refine reactive proxy logic
This commit is contained in:
parent
b3a76face9
commit
b826401bf3
6 changed files with 145 additions and 102 deletions
|
|
@ -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 = {}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue