diff --git a/src/_internal/utils/merge-config.ts b/src/_internal/utils/merge-config.ts index 00bff3601..936ec5604 100644 --- a/src/_internal/utils/merge-config.ts +++ b/src/_internal/utils/merge-config.ts @@ -1,12 +1,29 @@ import { mergeObjects } from './shared' import type { FullConfiguration } from '../types' +// Strip keys with `undefined` values from an object so they don't override +// defaults when spread-merged. This prevents issues like passing +// `{ onSuccess: undefined }` from overriding the default `noop` callback. +const stripUndefinedValues = (obj: Partial) => { + const result: Partial = {} + for (const key in obj) { + if (obj[key as keyof FullConfiguration] !== undefined) { + ;(result as any)[key] = obj[key as keyof FullConfiguration] + } + } + return result +} + export const mergeConfigs = ( a: Partial, b?: Partial ) => { // Need to create a new object to avoid mutating the original here. - const v: Partial = mergeObjects(a, b) + // Strip undefined values from `b` so they don't override defaults from `a`. + const v: Partial = mergeObjects( + a, + b ? stripUndefinedValues(b) : b + ) // If two configs are provided, merge their `use` and `fallback` options. if (b) { diff --git a/test/use-swr-config-callbacks.test.tsx b/test/use-swr-config-callbacks.test.tsx index db6438e8d..eb80cda58 100644 --- a/test/use-swr-config-callbacks.test.tsx +++ b/test/use-swr-config-callbacks.test.tsx @@ -200,6 +200,67 @@ describe('useSWR - config callbacks', () => { expect(discardedEvents).toEqual([key]) }) + it('should not cause infinite requests when onSuccess is explicitly undefined', async () => { + let count = 0 + const key = createKey() + function Page() { + const { data } = useSWR(key, () => createResponse(count++), { + onSuccess: undefined, + errorRetryInterval: 50, + dedupingInterval: 0 + }) + return
data: {data}
+ } + renderWithConfig() + await screen.findByText('data: 0') + + // Wait longer than the retry interval. If onSuccess: undefined caused a + // TypeError that triggered error-retry cycles, count would exceed 1. + await act(() => sleep(200)) + expect(count).toBe(1) + }) + + it('should not cause infinite requests when onError is explicitly undefined', async () => { + let count = 0 + const key = createKey() + function Page() { + const { data } = useSWR(key, () => createResponse(count++), { + onError: undefined, + errorRetryInterval: 50, + dedupingInterval: 0 + }) + return
data: {data}
+ } + renderWithConfig() + await screen.findByText('data: 0') + + await act(() => sleep(200)) + expect(count).toBe(1) + }) + + it('should handle a custom hook passing optional onSuccess that is undefined', async () => { + let count = 0 + const key = createKey() + + function useMyFetch({ onSuccess }: { onSuccess?: (data: any) => void }) { + return useSWR(key, () => createResponse(count++), { + onSuccess, + errorRetryInterval: 50, + dedupingInterval: 0 + }) + } + + function Page() { + const { data } = useMyFetch({}) + return
data: {data}
+ } + renderWithConfig() + await screen.findByText('data: 0') + + await act(() => sleep(200)) + expect(count).toBe(1) + }) + it('should not trigger the onSuccess callback when discarded', async () => { const key = createKey() const discardedEvents = []