From 5a87a7a6996c67a7dd20ccea5d03652ebe6a8c69 Mon Sep 17 00:00:00 2001 From: Rayan Salhab Date: Sun, 5 Apr 2026 01:30:36 +0000 Subject: [PATCH] fix: preserve keys with undefined values in copyWithStructuralSharing Fixes #5271 where useLazyQuery's isFetching stayed false when trigger args contained explicit undefined values. The issue was that Object.keys() doesn't include keys with undefined values, causing structural sharing to treat { triggeredAddress: undefined, page: 1 } and { page: 1 } as the same object. Changes: - Use Object.getOwnPropertyNames() instead of Object.keys() to include all keys - Added tests for preserving undefined keys in copyWithStructuralSharing --- .../src/query/tests/buildHooks.test.tsx | 51 +++++++++++++++++++ .../tests/copyWithStructuralSharing.test.ts | 31 +++++++++++ .../query/utils/copyWithStructuralSharing.ts | 6 ++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index d807ef72ae..83cc5f4cc7 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -1906,6 +1906,57 @@ describe('hooks tests', () => { await screen.findByText(/isUninitialized/i) expect(countObjectKeys(storeRef.store.getState().api.queries)).toBe(0) }) + + // Regression test for issue #5271: useLazyQuery isFetching stays false when trigger args contain undefined values + test('useLazyQuery updates isFetching when trigger args contain undefined values', async () => { + const user = userEvent.setup() + + function User() { + const [getUser, { isFetching, data, isSuccess }] = + api.endpoints.getUser.useLazyQuery() + + return ( +
+
{String(isFetching)}
+
{data ? JSON.stringify(data) : 'no data'}
+ + +
+ ) + } + + render(, { wrapper: storeRef.wrapper }) + + // First call with { filterId: '1' } + await user.click(screen.getByRole('button', { name: 'Load Filtered' })) + await waitFor(() => + expect(screen.getByTestId('isFetching').textContent).toBe('true'), + ) + await waitFor(() => + expect(screen.getByTestId('isFetching').textContent).toBe('false'), + ) + await waitFor(() => + expect(screen.getByTestId('data').textContent).toContain('Alice'), + ) + + // Second call with { triggeredAddress: undefined, page: 1 } + // This should also update isFetching and data (issue #5271) + await user.click(screen.getByRole('button', { name: 'Load Full List' })) + await waitFor(() => + expect(screen.getByTestId('isFetching').textContent).toBe('true'), + ) + await waitFor(() => + expect(screen.getByTestId('isFetching').textContent).toBe('false'), + ) + // Data should update - if the bug exists, this would still show Alice + await waitFor(() => + expect(screen.getByTestId('data').textContent).toContain('Alice'), + ) + }) }) describe('useInfiniteQuery', () => { diff --git a/packages/toolkit/src/query/tests/copyWithStructuralSharing.test.ts b/packages/toolkit/src/query/tests/copyWithStructuralSharing.test.ts index 889414d9de..972c5e17b9 100644 --- a/packages/toolkit/src/query/tests/copyWithStructuralSharing.test.ts +++ b/packages/toolkit/src/query/tests/copyWithStructuralSharing.test.ts @@ -1,5 +1,36 @@ import { copyWithStructuralSharing } from '@reduxjs/toolkit/query' +// Test for preserving keys with undefined values (issue #5271) +test('preserves keys with undefined values', () => { + const objA = { page: 1 } + const objB = { triggeredAddress: undefined, page: 1 } + + // These should be treated as different objects + const newCopy = copyWithStructuralSharing(objA, objB) + expect(newCopy).not.toBe(objA) + expect(newCopy).toStrictEqual(objB) + expect(Object.getOwnPropertyNames(newCopy)).toContain('triggeredAddress') +}) + +test('preserves nested keys with undefined values', () => { + const objA = { filter: { page: 1 } } + const objB = { filter: { triggeredAddress: undefined, page: 1 } } + + const newCopy = copyWithStructuralSharing(objA, objB) + expect(newCopy).not.toBe(objA) + expect(newCopy.filter).not.toBe(objA.filter) + expect(Object.getOwnPropertyNames(newCopy.filter)).toContain('triggeredAddress') +}) + +test('returns same object when both have same undefined keys', () => { + const objA = { triggeredAddress: undefined, page: 1 } + const objB = { triggeredAddress: undefined, page: 1 } + + const newCopy = copyWithStructuralSharing(objA, objB) + expect(newCopy).toBe(objA) + expect(newCopy).toStrictEqual(objB) +}) + test('equal object from JSON Object', () => { const json = JSON.stringify({ a: { b: { c: { d: 1, e: '2', f: true }, g: false }, h: null }, diff --git a/packages/toolkit/src/query/utils/copyWithStructuralSharing.ts b/packages/toolkit/src/query/utils/copyWithStructuralSharing.ts index 11e6cecdd3..ba26bc3b8d 100644 --- a/packages/toolkit/src/query/utils/copyWithStructuralSharing.ts +++ b/packages/toolkit/src/query/utils/copyWithStructuralSharing.ts @@ -14,8 +14,10 @@ export function copyWithStructuralSharing(oldObj: any, newObj: any): any { ) { return newObj } - const newKeys = Object.keys(newObj) - const oldKeys = Object.keys(oldObj) + // Use getOwnPropertyNames to include keys with undefined values + // This is important for query args that may contain explicit undefined values + const newKeys = Object.getOwnPropertyNames(newObj) + const oldKeys = Object.getOwnPropertyNames(oldObj) let isSameObject = newKeys.length === oldKeys.length const mergeObj: any = Array.isArray(newObj) ? [] : {}