diff --git a/.changeset/sharp-cooks-kiss.md b/.changeset/sharp-cooks-kiss.md new file mode 100644 index 00000000..100e8502 --- /dev/null +++ b/.changeset/sharp-cooks-kiss.md @@ -0,0 +1,16 @@ +--- +"@dojoengine/sdk": patch +"template-vite-ts": patch +"@dojoengine/core": patch +"@dojoengine/create-burner": patch +"@dojoengine/create-dojo": patch +"@dojoengine/predeployed-connector": patch +"@dojoengine/react": patch +"@dojoengine/state": patch +"@dojoengine/torii-client": patch +"@dojoengine/torii-wasm": patch +"@dojoengine/utils": patch +"@dojoengine/utils-wasm": patch +--- + +fix: zustand updateEntity and mergeEntities deeply merge objects diff --git a/packages/sdk/src/__tests__/state.test.ts b/packages/sdk/src/__tests__/state.test.ts index 68785473..9cebc384 100644 --- a/packages/sdk/src/__tests__/state.test.ts +++ b/packages/sdk/src/__tests__/state.test.ts @@ -111,6 +111,20 @@ describe("createDojoStore", () => { expect(state.entities["galaxy1"]).toEqual(initialGalaxy); }); + test("mergeEntities should merge entities to the store", () => { + useStore.getState().setEntities([initialPlayer]); + const updatedPlayer = { + entityId: "player1", + models: { world: { player: { score: 120 } }, universe: {} }, + }; + useStore.getState().mergeEntities([updatedPlayer]); + const state = useStore.getState(); + expect(state.getEntities()).toHaveLength(1); + const player = state.entities["player1"]; + expect(player.models.world?.player?.name).toEqual("Alice"); + expect(player.models.world?.player?.score).toEqual(120); + }); + test("updateEntity should update an existing entity", () => { useStore.getState().setEntities([initialPlayer]); useStore.getState().updateEntity({ diff --git a/packages/sdk/src/state/zustand.ts b/packages/sdk/src/state/zustand.ts index ac209f1e..3706f95e 100644 --- a/packages/sdk/src/state/zustand.ts +++ b/packages/sdk/src/state/zustand.ts @@ -20,6 +20,42 @@ type CreateStore = { ): StoreApi; }; +type MergedModels = + ParsedEntity["models"][keyof ParsedEntity["models"]]; + +function deepMerge( + target: MergedModels, + source: Partial> +): MergedModels { + const result = { ...target } as Record; + + for (const key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + if ( + source[key] !== null && + typeof source[key] === "object" && + !Array.isArray(source[key]) + ) { + // If the property is an object in both source and target, recursively merge + if ( + key in target && + typeof target[key] === "object" && + !Array.isArray(target[key]) + ) { + result[key] = deepMerge(target[key], source[key]); + } else { + // If the key doesn't exist in target or isn't an object, just assign + result[key] = source[key]; + } + } else { + // For non-objects (primitives, arrays, null), just assign + result[key] = source[key]; + } + } + } + + return result; +} /** * Factory function to create a Zustand store based on a given SchemaType. * @@ -49,66 +85,14 @@ export function createDojoStoreFactory( const existingEntity = state.entities[entity.entityId]; - if (existingEntity) { - // Create new models object without spread - const mergedModels: typeof existingEntity.models = - Object.assign( - {}, - existingEntity.models - ); - - // Iterate through each namespace in the new models - Object.entries(entity.models).forEach( - ([namespace, namespaceModels]) => { - const typedNamespace = - namespace as keyof ParsedEntity["models"]; - if ( - !( - typedNamespace in - mergedModels - ) - ) { - mergedModels[ - typedNamespace as keyof typeof mergedModels - ] = {} as any; - } - - mergedModels[ - typedNamespace as keyof typeof mergedModels - ] = Object.assign( - {}, - mergedModels[ - typedNamespace as keyof typeof mergedModels - ], - namespaceModels - ); - } - ); - - // Update the entity - state.entities[entity.entityId] = { - ...existingEntity, - ...entity, - models: mergedModels, - }; - } else { + if (!existingEntity) { // Set new entity state.entities[entity.entityId] = entity as WritableDraft< ParsedEntity >; + return; } - } - }); - }); - }, - updateEntity: (entity: Partial>) => { - set((state: Draft>) => { - if (entity.entityId && entity.models) { - const existingEntity = - state.entities[entity.entityId]; - - if (existingEntity) { // Create new models object without spread const mergedModels: typeof existingEntity.models = Object.assign({}, existingEntity.models); @@ -124,28 +108,71 @@ export function createDojoStoreFactory( ] = {} as any; } + // Use deep merge instead of Object.assign mergedModels[ typedNamespace as keyof typeof mergedModels - ] = Object.assign( - {}, + ] = deepMerge( mergedModels[ typedNamespace as keyof typeof mergedModels - ], + ] as MergedModels, namespaceModels - ); + ) as any; } ); + // Update the entity state.entities[entity.entityId] = { ...existingEntity, ...entity, models: mergedModels, }; - } else { + } + }); + }); + }, + updateEntity: (entity: Partial>) => { + set((state: Draft>) => { + if (entity.entityId && entity.models) { + const existingEntity = + state.entities[entity.entityId]; + + if (!existingEntity) { // Set new entity state.entities[entity.entityId] = entity as WritableDraft>; + return; } + // Create new models object without spread + const mergedModels: typeof existingEntity.models = + Object.assign({}, existingEntity.models); + + // Iterate through each namespace in the new models + Object.entries(entity.models).forEach( + ([namespace, namespaceModels]) => { + const typedNamespace = + namespace as keyof ParsedEntity["models"]; + if (!(typedNamespace in mergedModels)) { + mergedModels[ + typedNamespace as keyof typeof mergedModels + ] = {} as any; + } + + mergedModels[ + typedNamespace as keyof typeof mergedModels + ] = deepMerge( + mergedModels[ + typedNamespace as keyof typeof mergedModels + ] as MergedModels, + namespaceModels + ) as any; + } + ); + // Update the entity + state.entities[entity.entityId] = { + ...existingEntity, + ...entity, + models: mergedModels, + }; } }); },