From 366fc668a2ec75c002658cbd83bcfec36791f82f Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:05:59 -0800 Subject: [PATCH] fix: check for nil diffs --- .../charm-sync/src/__tests__/server.spec.luau | 40 +++++++++++++++++++ packages/charm-sync/src/server.luau | 26 ++++++------ packages/charm/src/init.luau | 28 ++++++------- 3 files changed, 67 insertions(+), 27 deletions(-) diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau index 4a30449..800fa88 100644 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -210,6 +210,26 @@ test("should serialize state", function() assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key") end) +test("should not send unchanged state", function() + local client = {} + local a = Charm.atom(0) + local payloads + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, p) + payloads = p + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + payloads = {} + a(0) + server.flush() + assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") +end) + describe("historical", function() beforeEach(function() config.preserveHistory = true @@ -344,6 +364,26 @@ describe("historical", function() assert(payloads[4].type == "patch", "Expected fourth payload to be patch") assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'") end) + + test("should not send unchanged state", function() + local client = {} + local a = Charm.atom(0) + local payloads + + server.addSignalsToClient(client, { a = a }) + server.connect(function(_, p) + payloads = p + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + payloads = {} + a(0) + server.flush() + assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") + end) end) return {} diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index d9b1694..9624d1c 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -75,24 +75,24 @@ local function trackReactive(key: string, proxy: ReactiveProxy) return end - local meta = getmetatable(proxy) - local snapshot = utils.deepCopy(meta.value) + local api = getmetatable(proxy) + local snapshot = utils.deepCopy(api.value) local firstRun = true local trackedState = { target = proxy, - currentValue = meta.value, + currentValue = api.value, lastSyncedValue = snapshot, isReactive = true, } trackedState.dispose = Charm.effect(function() - meta.track() + api.track() if not config.preserveHistory or firstRun then firstRun = false else local oldSnapshot = snapshot - snapshot = utils.deepCopy(meta.value) + snapshot = utils.deepCopy(api.value) table.insert(globalUpdates, { key = key, newValue = snapshot, @@ -132,8 +132,8 @@ local function diffGlobalUpdateBuffer(): { any } local patchCount = #globalUpdates local patches = table.create(patchCount) - for index, update in globalUpdates do - patches[index] = patch.diff(update.oldValue, update.newValue) + for _, update in globalUpdates do + table.insert(patches, patch.diff(update.oldValue, update.newValue)) end return patches @@ -176,7 +176,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { -- If a pending signal was never updated, we still need to send its initial -- state. This can happen if a signal was registered after its last change. - local initData: { [string]: any } = {} + local initData = {} for key, status in clientKeys do local trackedState = trackedStates[key] @@ -194,7 +194,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { return payloads end -local function diffGlobalState(): any +local function diffGlobalState(): { [string]: any }? local currentGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {} @@ -211,7 +211,7 @@ local function diffGlobalState(): any return patch.diff(previousGlobalState, currentGlobalState) end -local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }? +local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }? local clientKeys = clients[client] if not clientKeys then @@ -227,7 +227,7 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo for key, status in clientKeys do local trackedState = trackedStates[key] - if status == LISTENING_FOR_CHANGES then + if status == LISTENING_FOR_CHANGES and globalPatch then patchData[key] = globalPatch[key] elseif status == PENDING_INITIAL_STATE then initData[key] = trackedState.currentValue @@ -323,11 +323,11 @@ local function flush() end local granular = config.preserveHistory - local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState() + local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState() for client in clients do local payloads = if granular - then selectFromGlobalPatchList(client, globalPatch) + then selectFromGlobalPatchList(client, globalPatch :: { any }) else selectFromGlobalPatch(client, globalPatch) if payloads and payloads[1] then diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 7b6f4ae..85d95f2 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -6,7 +6,7 @@ export type Link = system.Link export type Atom = (...((T) -> T) | T) -> T -type ReactiveProxyMeta = { +type ReactiveProxyApi = { value: T, track: () -> (), set: (T | (T) -> ()?) -> (), @@ -17,7 +17,7 @@ type ReactiveProxyProps = { __signals: { [unknown]: () -> unknown }, } -export type ReactiveProxy = T & typeof(setmetatable({} :: ReactiveProxyProps, {} :: ReactiveProxyMeta)) +export type ReactiveProxy = T & typeof(setmetatable({} :: ReactiveProxyProps, {} :: ReactiveProxyApi)) type EffectScopeNode = ReactiveNode & { cleanups: { () -> () }, @@ -855,7 +855,7 @@ end ]] local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ()) local track = signal(nil) - local proxyMeta = { value = initialValue, track = track } + local proxyApi = { value = initialValue, track = track } local proxyMap = setmetatable({} :: { [{}]: any }, { __mode = "v" }) local function trackProperty(cache: { [any]: () -> any }, target: {}, key: any) @@ -876,11 +876,11 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - proxyMap[target] = setmetatable({ __target = target, __signals = setmetatable({}, { __mode = "v" }), - }, proxyMeta) + }, proxyApi) return proxyMap[target] end - function proxyMeta.__index(self: ReactiveProxy, key: any): any + function proxyApi.__index(self: ReactiveProxy, key: any): any if activeSub then trackProperty(self.__signals, self.__target, key) end @@ -888,8 +888,8 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - return if type(value) == "table" then createProxy(value) else value end - function proxyMeta.__newindex(self: ReactiveProxy, key: any, value: any) - if type(value) == "table" and getmetatable(value) == proxyMeta then + function proxyApi.__newindex(self: ReactiveProxy, key: any, value: any) + if type(value) == "table" and getmetatable(value) == proxyApi then value = value.__target end if self.__target[key] ~= value then @@ -898,7 +898,7 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - end end - function proxyMeta.__iter(self: ReactiveProxy) + function proxyApi.__iter(self: ReactiveProxy) track() local function iterator(target: {}, index: any) local key, value = next(target, index) @@ -907,12 +907,12 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - return iterator, self.__target end - function proxyMeta.__len(self: ReactiveProxy) + function proxyApi.__len(self: ReactiveProxy) track() return #self.__target end - function proxyMeta.set(update: ((T) -> ()) | T?) + function proxyApi.set(update: ((T) -> ()) | T?) if type(update) == "function" then update(initialValue) elseif type(update) == "table" then @@ -924,7 +924,7 @@ local function reactive(initialValue: T & {}): (T, (update: (T) -> () | T?) - trigger(track) end - return createProxy(initialValue), proxyMeta.set + return createProxy(initialValue), proxyApi.set end --[[ @@ -938,10 +938,10 @@ end local function toRaw(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 + local api = getmetatable(proxy) + if api.value == proxy.__target then -- Track the root proxy since nested proxies are already tracked - meta.track() + api.track() end return proxy.__target :: any else