From a2949519b0fca89d18014f9ceee4a285bf1b3bc9 Mon Sep 17 00:00:00 2001 From: Alex Licata Date: Thu, 23 Oct 2025 12:47:53 +0200 Subject: [PATCH 1/3] add test to demonstrate https://github.com/LegendApp/legend-state/issues/410 --- tests/crud.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/crud.test.ts b/tests/crud.test.ts index e2064fb7..5fd0e0c8 100644 --- a/tests/crud.test.ts +++ b/tests/crud.test.ts @@ -2769,6 +2769,42 @@ describe('onSaved', () => { updatedAt: 12, }); }); + + test('onSaved gets correct value as object', async () => { + let saved = undefined; + const obs = observable( + syncedCrud({ + as: 'object', + fieldUpdatedAt: 'updatedAt', + create: async (input: BasicValue) => { + return input; + }, + generateId: () => 'id1', + onSaved(params) { + params.saved = { ...params.saved, parent: { child: { baby: 'hello baby override' } } }; + saved = params.saved; + return params.saved; + }, + }), + ); + + await promiseTimeout(1); + + obs.id1.set({ test: 'hello', id: undefined as unknown as string, parent: { child: { baby: 'hello baby' } } }); + + await promiseTimeout(1); + + expect(saved).toEqual({ + id: 'id1', + test: 'hello', + parent: { child: { baby: 'hello baby override' } }, + }); + expect(obs.id1.peek()).toEqual({ + id: 'id1', + test: 'hello', + parent: { child: { baby: 'hello baby override' } }, + }); + }); }); describe('Order of get/create', () => { test('create with no get', async () => { From c09b2b559d872e561804a56c8319798932a110c3 Mon Sep 17 00:00:00 2001 From: Alex Licata Date: Thu, 23 Oct 2025 12:53:59 +0200 Subject: [PATCH 2/3] fix(crud): use deepEqual for object change detection Compare local and remote object values by deep equality, not reference, when reconciling fields in syncedCrud. Prevents false positives when objects are structurally equal. --- src/sync-plugins/crud.ts | 2 +- tests/crud.test.ts | 43 +++++++++++++++++++++++++++++++++++----- tests/testglobals.ts | 9 ++++++++- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/sync-plugins/crud.ts b/src/sync-plugins/crud.ts index 38cd8a53..ed1bf5cd 100644 --- a/src/sync-plugins/crud.ts +++ b/src/sync-plugins/crud.ts @@ -605,7 +605,7 @@ export function syncedCrud { }); }); - test('onSaved gets correct value as object', async () => { + test('onSaved gets correct value as object with deep nested object containing array', async () => { let saved = undefined; const obs = observable( syncedCrud({ @@ -2781,7 +2781,17 @@ describe('onSaved', () => { }, generateId: () => 'id1', onSaved(params) { - params.saved = { ...params.saved, parent: { child: { baby: 'hello baby override' } } }; + params.saved = { + ...params.saved, + parent: { + child: { baby: 'hello baby override' }, + }, + list: [ + { parent: { child: { baby: 'hello list baby 1' } } }, + { parent: { child: { baby: 'hello list baby 2 modified' } } }, + { parent: { child: { baby: 'hello list baby 3' } } }, + ], + }; saved = params.saved; return params.saved; }, @@ -2790,19 +2800,42 @@ describe('onSaved', () => { await promiseTimeout(1); - obs.id1.set({ test: 'hello', id: undefined as unknown as string, parent: { child: { baby: 'hello baby' } } }); + obs.id1.set({ + test: 'hello', + id: undefined as unknown as string, + parent: { child: { baby: 'hello baby' } }, + list: [ + { parent: { child: { baby: 'hello list baby 1' } } }, + { parent: { child: { baby: 'hello list baby 2' } } }, + { parent: { child: { baby: 'hello list baby 3' } } }, + ], + }); await promiseTimeout(1); expect(saved).toEqual({ id: 'id1', test: 'hello', - parent: { child: { baby: 'hello baby override' } }, + parent: { + child: { baby: 'hello baby override' }, + }, + list: [ + { parent: { child: { baby: 'hello list baby 1' } } }, + { parent: { child: { baby: 'hello list baby 2 modified' } } }, + { parent: { child: { baby: 'hello list baby 3' } } }, + ], }); expect(obs.id1.peek()).toEqual({ id: 'id1', test: 'hello', - parent: { child: { baby: 'hello baby override' } }, + parent: { + child: { baby: 'hello baby override' }, + }, + list: [ + { parent: { child: { baby: 'hello list baby 1' } } }, + { parent: { child: { baby: 'hello list baby 2 modified' } } }, + { parent: { child: { baby: 'hello list baby 3' } } }, + ], }); }); }); diff --git a/tests/testglobals.ts b/tests/testglobals.ts index a994db9e..a2ed0a9a 100644 --- a/tests/testglobals.ts +++ b/tests/testglobals.ts @@ -1,7 +1,7 @@ import { jest } from '@jest/globals'; -import { ObservablePersistLocalStorageBase } from '../src/persist-plugins/local-storage'; import type { Change, TrackingType } from '../src/observableInterfaces'; import type { Observable } from '../src/observableTypes'; +import { ObservablePersistLocalStorageBase } from '../src/persist-plugins/local-storage'; export interface BasicValue { id: string; @@ -13,6 +13,13 @@ export interface BasicValue { baby: string; }; }; + list?: { + parent?: { + child: { + baby: string; + }; + }; + }[]; } export interface BasicValue2 { id: string; From cd40841ebc5cc0ded5c24bb10d0f5395f9df697c Mon Sep 17 00:00:00 2001 From: Alex Licata Date: Thu, 23 Oct 2025 16:14:38 +0200 Subject: [PATCH 3/3] feat(deepEqual): add deep array comparison support --- src/sync/syncHelpers.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/sync/syncHelpers.ts b/src/sync/syncHelpers.ts index 140e355b..64af4913 100644 --- a/src/sync/syncHelpers.ts +++ b/src/sync/syncHelpers.ts @@ -39,6 +39,15 @@ export function deepEqual = any>( ): boolean { if (a === b) return true; if (isNullOrUndefined(a) !== isNullOrUndefined(b)) return false; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i], ignoreFields, nullVsUndefined)) return false; + } + return true; + } + if (!isObject(a) || !isObject(b)) return a === b; if (nullVsUndefined) {