diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 1435619..ba92e30 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -5,11 +5,10 @@ local types = require("./types") type ReactiveProxy = Charm.ReactiveProxy type SyncPayload = types.SyncPayload -type SignalSetter = (T | (T) -> T) -> () -type ReactiveSetter = (T | (T) -> ()) -> () +type Setter = (T | (T) -> any) -> () -local trackedSignals: { [string]: SignalSetter } = {} -local trackedReactives: { [string]: ReactiveSetter } = {} +local trackedSignals: { [string]: Setter } = {} +local trackedReactives: { [string]: Setter } = {} --[[ Registers a map of setters to the state with the corresponding keys on the @@ -22,7 +21,7 @@ local trackedReactives: { [string]: ReactiveSetter } = {} @param signals A map of setter functions to sync with the server. ]] -local function addSignals(signals: { [string]: SignalSetter | {} }) +local function addSignals(signals: { [string]: Setter | {} }) for key, signal in signals do if type(signal) == "function" then trackedSignals[key] = signal @@ -55,7 +54,7 @@ end local function applyInitPayload(data: { [string]: any }) for key, value in data do - local trackedSignal: SignalSetter? = trackedSignals[key] or trackedReactives[key] + local trackedSignal: Setter? = trackedSignals[key] or trackedReactives[key] if trackedSignal then trackedSignal(value) end @@ -72,7 +71,7 @@ local function applyPatchPayload(data: { [string]: any }) return patch.apply(value, diffs) end) elseif trackedReactive then - trackedReactive(function(value) + trackedReactive(function(value): () patch.applyMutable(value, diffs) end) end diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 0d1684d..b9a3be1 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -111,16 +111,16 @@ local function apply(oldState: any, diffs: any): any return newState end -local function applyMutable(targetState: any, sourceState: any): any - if isNone(sourceState) then +local function applyMutable(targetState: any, diffs: any): any + if isNone(diffs) then return nil - elseif type(targetState) ~= "table" or type(sourceState) ~= "table" then - return sourceState + elseif type(targetState) ~= "table" or type(diffs) ~= "table" then + return diffs end local targetIsArray = targetState[1] ~= nil - for key, value in sourceState do + for key, value in diffs do if targetIsArray and type(key) == "string" then key = tonumber(key) or key end diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 3605521..7233fd7 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -12,10 +12,10 @@ type SyncStatus = number type ClientKeys = { [string]: SyncStatus } type TrackedState = { - target: any, + identifier: unknown, currentValue: T, lastSyncedValue: T, - isReactive: boolean?, + isReactive: boolean, dispose: () -> (), } @@ -35,75 +35,54 @@ local clients: { [any]: ClientKeys } = {} local connection: RBXScriptConnection? local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? -local function trackSignal(key: string, getter: () -> T) +local function trackSignal(key: string, getter: () -> T, identifier: unknown) if trackedStates[key] then -- Ignore duplicates, but warn against name collisions - if trackedStates[key].target ~= getter then + if trackedStates[key].identifier ~= identifier then warn("[Charm] Attempted to sync different states with the same key:", key) end return end - local value = getter() - local trackedState = { - target = getter, - currentValue = value, - lastSyncedValue = value, - } + local trackedState: TrackedState + local initialValue = getter() + local rawInitialValue = Charm.toRaw(initialValue) + local initialIsReactive = initialValue ~= rawInitialValue + local snapshot = if initialIsReactive then utils.deepCopy(rawInitialValue) else initialValue + + local dispose = Charm.effect(function() + local newValue = getter() + local rawValue = Charm.toRaw(newValue) + local isReactive = newValue ~= rawValue + + if not trackedState then + return + end - trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue) -- When preserving history, we need to keep track of previous values -- so we can create granular patches for each change. if config.preserveHistory then - table.insert(globalUpdates, { - key = key, - newValue = newValue, - oldValue = oldValue, - }) - end - trackedState.currentValue = newValue - end) - - trackedStates[key] = trackedState -end - -local function trackReactive(key: string, proxy: ReactiveProxy) - if trackedStates[key] then - if trackedStates[key].target ~= proxy then - warn("[Charm] Attempted to sync different states with the same key:", key) - end - return - end - - local api = getmetatable(proxy) - local snapshot = utils.deepCopy(api.value) - local firstRun = true - - local trackedState = { - target = proxy, - currentValue = api.value, - lastSyncedValue = snapshot, - isReactive = true, - } - - trackedState.dispose = Charm.effect(function() - api.track() - if not config.preserveHistory then - return - end - if firstRun then - firstRun = false - else local oldSnapshot = snapshot - snapshot = utils.deepCopy(api.value) + snapshot = if isReactive then utils.deepCopy(rawValue) else newValue table.insert(globalUpdates, { key = key, newValue = snapshot, oldValue = oldSnapshot, }) end + + trackedState.currentValue = rawValue + trackedState.isReactive = isReactive end) + trackedState = { + identifier = identifier, + currentValue = rawInitialValue, + lastSyncedValue = snapshot, + isReactive = initialIsReactive, + dispose = dispose, + } + trackedStates[key] = trackedState end @@ -269,9 +248,11 @@ local function addSignalsToClient(client: any, signals: { [string]: {} | () -> a for key, signal in signals do if type(signal) == "function" then - trackSignal(key, signal) + trackSignal(key, signal, signal) else - trackReactive(key, signal :: any) + trackSignal(key, function() + return signal + end, signal) end if not clientKeys[key] then diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 247fe91..132a5be 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -309,6 +309,15 @@ export function reactive( initialValue: T, ): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>; +/** + * Determines if the provided value is a reactive proxy created by `reactive()`. + * + * @param value A value to test. + * @returns `true` if the value is a reactive proxy, `false` otherwise. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function isReactive(value: unknown): boolean; + /** * Unwraps a reactive proxy to get the original table. If the provided value * is not a proxy, it is returned as-is. diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index ca45ce9..4f0cd11 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -923,6 +923,17 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - return createProxy(initialValue), proxyApi.set 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 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. @@ -932,12 +943,9 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue ]] local function toRaw(value: T): T - if type(value) == "table" and type(value.__target) == "table" then - -- Iteration starts tracking the reactive proxy - for _ in value do - break - end - return value.__target + if isReactive(value) then + getmetatable(value :: ReactiveProxy).track() + return (value :: ReactiveProxy).__target end return value end @@ -980,5 +988,6 @@ return { mapper: (item: VI, key: KI) -> (VO, KO) ) -> () -> { [KO]: VO }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), reactive = reactive, + isReactive = isReactive, toRaw = toRaw, }