From 4901804543102121fbb3d45c3e2e278cbafc9ac7 Mon Sep 17 00:00:00 2001 From: Richard Willis Date: Mon, 24 Aug 2026 13:37:22 +0100 Subject: [PATCH 1/3] fix: deepMerge no longer mutates its arguments deepMerge merged into its target and returned it, and also assigned into its source. Generated hooks call deepMerge(fetcherOptions, variables), and fetcherOptions comes from a single useContext() call per hook instance, so that object is shared by every call of the hook. Each call therefore leaked its payload into the next: concurrent writes could all send the last one's body, sequential writes carried stale fields from the previous one, and the caller's own variables object was rewritten behind its back. Every request stayed well formed and succeeded, so the corruption was silent. deepMerge now builds a new object and mutates neither argument. It also only recurses when both sides are plain objects, so arrays are replaced rather than merged by index, and FormData/File bodies are passed through untouched. The existing merge test is unchanged and still passes, so the behaviour #284 introduced is preserved. Closes #349 --- examples/frontend/src/github/githubUtils.ts | 22 ++++- .../generateReactQueryComponents.test.ts | 22 ++++- .../typescript/src/templates/utils.test.ts | 95 +++++++++++++++++-- plugins/typescript/src/templates/utils.ts | 22 ++++- 4 files changed, 136 insertions(+), 25 deletions(-) diff --git a/examples/frontend/src/github/githubUtils.ts b/examples/frontend/src/github/githubUtils.ts index 17bb455..151f3a5 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..7292792 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..48c4748 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,67 @@ describe("deepMerge", () => { queryParams: { media: "vite testing" }, }); }); + + /** + * Mirrors a generated `*Variables` type: every field optional, so any subset + * of the fetcher options satisfies the `U extends T` constraint. + */ + 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); + }); + + /** + * Generated hooks call `deepMerge(fetcherOptions, variables)`, where + * `fetcherOptions` comes from a single `useContext()` call per hook instance + * and is therefore shared by every call of that hook. A merge that mutated + * its target would leak each call's payload into the next. + */ + 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..05e9471 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; } `; From 7d23893277c0eb137f78fd5b94c97673076c8636 Mon Sep 17 00:00:00 2001 From: Richard Willis Date: Mon, 24 Aug 2026 13:42:11 +0100 Subject: [PATCH 2/3] chore: drop explanatory comments from deepMerge tests --- plugins/typescript/src/templates/utils.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/plugins/typescript/src/templates/utils.test.ts b/plugins/typescript/src/templates/utils.test.ts index 48c4748..f291725 100644 --- a/plugins/typescript/src/templates/utils.test.ts +++ b/plugins/typescript/src/templates/utils.test.ts @@ -46,10 +46,6 @@ describe("deepMerge", () => { }); }); - /** - * Mirrors a generated `*Variables` type: every field optional, so any subset - * of the fetcher options satisfies the `U extends T` constraint. - */ type variables = { headers?: { "x-custom"?: boolean; authorization?: string }; body?: Record; @@ -87,12 +83,6 @@ describe("deepMerge", () => { expect(deepMerge({ body: new FormData() }, { body }).body).toBe(body); }); - /** - * Generated hooks call `deepMerge(fetcherOptions, variables)`, where - * `fetcherOptions` comes from a single `useContext()` call per hook instance - * and is therefore shared by every call of that hook. A merge that mutated - * its target would leak each call's payload into the next. - */ it("should not leak state between calls sharing a target", () => { const fetcherOptions: variables = { headers: { authorization: "authorized!" }, From 118d6c44bad9e2efd82aa541242b91a9f1705193 Mon Sep 17 00:00:00 2001 From: Richard Willis Date: Mon, 24 Aug 2026 13:56:34 +0100 Subject: [PATCH 3/3] refactor: drop redundant empty-object fallback in deepMerge spread --- examples/frontend/src/github/githubUtils.ts | 2 +- .../src/generators/generateReactQueryComponents.test.ts | 2 +- plugins/typescript/src/templates/utils.test.ts | 2 +- plugins/typescript/src/templates/utils.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/frontend/src/github/githubUtils.ts b/examples/frontend/src/github/githubUtils.ts index 151f3a5..a315516 100644 --- a/examples/frontend/src/github/githubUtils.ts +++ b/examples/frontend/src/github/githubUtils.ts @@ -24,7 +24,7 @@ function isMergeableObject(value: unknown): value is Record { } export function deepMerge(target: T, source: U): U { - const result = { ...(target || {}) } as U; + const result = { ...target } as U; for (const key in source) { const sourceValue = source[key]; const targetValue = result[key]; diff --git a/plugins/typescript/src/generators/generateReactQueryComponents.test.ts b/plugins/typescript/src/generators/generateReactQueryComponents.test.ts index 7292792..664921b 100644 --- a/plugins/typescript/src/generators/generateReactQueryComponents.test.ts +++ b/plugins/typescript/src/generators/generateReactQueryComponents.test.ts @@ -2858,7 +2858,7 @@ describe("generateReactQueryComponents", () => { } export function deepMerge(target: T, source: U): U { - const result = { ...(target || {}) } as U; + const result = { ...target } as U; for (const key in source) { const sourceValue = source[key]; const targetValue = result[key]; diff --git a/plugins/typescript/src/templates/utils.test.ts b/plugins/typescript/src/templates/utils.test.ts index f291725..68b2892 100644 --- a/plugins/typescript/src/templates/utils.test.ts +++ b/plugins/typescript/src/templates/utils.test.ts @@ -12,7 +12,7 @@ describe("deepMerge", () => { } function deepMerge(target: T, source: U): U { - const result = { ...(target || {}) } as U; + const result = { ...target } as U; for (const key in source) { const sourceValue = source[key]; const targetValue = result[key]; diff --git a/plugins/typescript/src/templates/utils.ts b/plugins/typescript/src/templates/utils.ts index 05e9471..102e24d 100644 --- a/plugins/typescript/src/templates/utils.ts +++ b/plugins/typescript/src/templates/utils.ts @@ -24,7 +24,7 @@ function isMergeableObject(value: unknown): value is Record { } export function deepMerge(target: T, source: U): U { - const result = { ...(target || {}) } as U; + const result = { ...target } as U; for (const key in source) { const sourceValue = source[key]; const targetValue = result[key];