diff --git a/examples/frontend/src/github/githubUtils.ts b/examples/frontend/src/github/githubUtils.ts index 17bb455..a315516 100644 --- a/examples/frontend/src/github/githubUtils.ts +++ b/examples/frontend/src/github/githubUtils.ts @@ -14,12 +14,24 @@ export type ServerErrorStatus = Exclude< ComputeRange<500>[number] >; +function isMergeableObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + (value.constructor === Object || value.constructor === undefined) + ); +} + export function deepMerge(target: T, source: U): U { - const returnType = (target || {}) as U; + const result = { ...target } as U; for (const key in source) { - if (source[key] instanceof Object) - Object.assign(source[key], deepMerge(returnType[key], source[key])); + const sourceValue = source[key]; + const targetValue = result[key]; + result[key] = + isMergeableObject(targetValue) && isMergeableObject(sourceValue) + ? (deepMerge(targetValue, sourceValue) as U[Extract]) + : sourceValue; } - Object.assign(returnType || {}, source); - return returnType; + return result; } diff --git a/plugins/typescript/src/generators/generateReactQueryComponents.test.ts b/plugins/typescript/src/generators/generateReactQueryComponents.test.ts index 7c83cc5..664921b 100644 --- a/plugins/typescript/src/generators/generateReactQueryComponents.test.ts +++ b/plugins/typescript/src/generators/generateReactQueryComponents.test.ts @@ -2848,14 +2848,26 @@ describe("generateReactQueryComponents", () => { ComputeRange<500>[number] >; + function isMergeableObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + (value.constructor === Object || value.constructor === undefined) + ); + } + export function deepMerge(target: T, source: U): U { - const returnType = (target || {}) as U; + const result = { ...target } as U; for (const key in source) { - if (source[key] instanceof Object) - Object.assign(source[key], deepMerge(returnType[key], source[key])); + const sourceValue = source[key]; + const targetValue = result[key]; + result[key] = + isMergeableObject(targetValue) && isMergeableObject(sourceValue) + ? (deepMerge(targetValue, sourceValue) as U[Extract]) + : sourceValue; } - Object.assign(returnType || {}, source); - return returnType; + return result; } " `); diff --git a/plugins/typescript/src/templates/utils.test.ts b/plugins/typescript/src/templates/utils.test.ts index 5ceffd4..68b2892 100644 --- a/plugins/typescript/src/templates/utils.test.ts +++ b/plugins/typescript/src/templates/utils.test.ts @@ -2,23 +2,35 @@ import { it, describe, expect } from "vitest"; describe("deepMerge", () => { /* Playground to craft `deepMerge` */ + function isMergeableObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + (value.constructor === Object || value.constructor === undefined) + ); + } + function deepMerge(target: T, source: U): U { - const returnType = (target || {}) as U; + const result = { ...target } as U; for (const key in source) { - if (source[key] instanceof Object) - Object.assign(source[key], deepMerge(returnType[key], source[key])); + const sourceValue = source[key]; + const targetValue = result[key]; + result[key] = + isMergeableObject(targetValue) && isMergeableObject(sourceValue) + ? (deepMerge(targetValue, sourceValue) as U[Extract]) + : sourceValue; } - Object.assign(returnType || {}, source); - return returnType; + return result; } /* End of playground */ - it("should merge two objects", () => { - type fetcherOptions = { - headers: { "x-custom"?: boolean; authorization: string }; - queryParams?: { media?: string }; - }; + type fetcherOptions = { + headers: { "x-custom"?: boolean; authorization: string }; + queryParams?: { media?: string }; + }; + it("should merge two objects", () => { const a: fetcherOptions = { headers: { "x-custom": true, authorization: "nope!" }, queryParams: { media: "vite testing" }, @@ -33,4 +45,57 @@ describe("deepMerge", () => { queryParams: { media: "vite testing" }, }); }); + + type variables = { + headers?: { "x-custom"?: boolean; authorization?: string }; + body?: Record; + }; + + it("should not mutate the target", () => { + const target: variables = { + headers: { "x-custom": true, authorization: "nope!" }, + }; + + deepMerge(target, { headers: { authorization: "authorized!" } }); + + expect(target).toEqual({ + headers: { "x-custom": true, authorization: "nope!" }, + }); + }); + + it("should not mutate the source", () => { + const target: variables = { headers: { "x-custom": true } }; + const source: variables = { headers: { authorization: "authorized!" } }; + + deepMerge(target, source); + + expect(source).toEqual({ headers: { authorization: "authorized!" } }); + }); + + it("should replace arrays instead of merging them by index", () => { + expect(deepMerge({ ids: [1, 2, 3] }, { ids: [9] })).toEqual({ ids: [9] }); + }); + + it("should not merge into non-plain objects", () => { + const body = new FormData(); + body.append("file", "content"); + + expect(deepMerge({ body: new FormData() }, { body }).body).toBe(body); + }); + + it("should not leak state between calls sharing a target", () => { + const fetcherOptions: variables = { + headers: { authorization: "authorized!" }, + }; + + const first = deepMerge(fetcherOptions, { + body: { title: "first", archivedAt: "2026-01-01" }, + }); + const second = deepMerge(fetcherOptions, { + body: { title: "second" }, + }); + + expect(first.body).toEqual({ title: "first", archivedAt: "2026-01-01" }); + expect(second.body).toEqual({ title: "second" }); + }); }); diff --git a/plugins/typescript/src/templates/utils.ts b/plugins/typescript/src/templates/utils.ts index 3a700bf..102e24d 100644 --- a/plugins/typescript/src/templates/utils.ts +++ b/plugins/typescript/src/templates/utils.ts @@ -14,13 +14,25 @@ export const getUtils = () => export type ${clientErrorStatus} = Exclude[number], ComputeRange<400>[number]>; export type ${serverErrorStatus} = Exclude[number], ComputeRange<500>[number]>; +function isMergeableObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + (value.constructor === Object || value.constructor === undefined) + ); +} + export function deepMerge(target: T, source: U): U { - const returnType = (target || {}) as U; + const result = { ...target } as U; for (const key in source) { - if (source[key] instanceof Object) - Object.assign(source[key], deepMerge(returnType[key], source[key])); + const sourceValue = source[key]; + const targetValue = result[key]; + result[key] = + isMergeableObject(targetValue) && isMergeableObject(sourceValue) + ? (deepMerge(targetValue, sourceValue) as U[Extract]) + : sourceValue; } - Object.assign(returnType || {}, source); - return returnType; + return result; } `;