fix: safely serialize query/mutation data to prevent DataCloneError with framework proxies#182
Conversation
…ith framework proxies Frameworks like Vue 3, MobX, and Solid wrap state in Proxy objects. When mapQueryToData / mapMutationToData pass these values directly into the payload sent via window.postMessage, the structured clone algorithm fails with a DataCloneError. Add a safeClone helper that JSON-round-trips values before they are included in the message payload. This reads through Proxy getters and produces plain objects that are safe for structured cloning. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
This pull request addresses a serialization issue when using TanStack Query DevTools with reactive frameworks (Vue 3, MobX, Solid) that wrap state in Proxy objects. The structured clone algorithm used by window.postMessage fails with DataCloneError when encountering these proxies. The solution adds a safeClone helper that uses JSON round-tripping to convert Proxy objects into plain objects before they are sent via postMessage.
Changes:
- Added
safeClonehelper function using JSON.parse(JSON.stringify()) to handle Proxy objects - Applied
safeCloneto query fields: queryKey, state.data, state.error, and meta - Applied
safeCloneto mutation fields: variables, context, data, and error
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function safeClone<T>(value: T): T { | ||
| try { | ||
| return JSON.parse(JSON.stringify(value)); | ||
| } catch { | ||
| return "[Unserializable]" as T; |
There was a problem hiding this comment.
The return type "[Unserializable]" as T creates a type safety issue. When T is expected to be an object or array (like queryKey, which is QueryKey type), returning a string breaks the type contract. This could cause runtime errors in code that expects the original type structure.
Consider either:
- Returning
{} as Tfor objects/arrays to maintain structural compatibility - Adding proper type narrowing to handle different value types appropriately
- Using a union type that allows the function to return the original type or a serialization error indicator
| function safeClone<T>(value: T): T { | |
| try { | |
| return JSON.parse(JSON.stringify(value)); | |
| } catch { | |
| return "[Unserializable]" as T; | |
| function safeClone<T>(value: T): T | "[Unserializable]" { | |
| try { | |
| return JSON.parse(JSON.stringify(value)); | |
| } catch { | |
| return "[Unserializable]"; |
| * JSON round-tripping reads through Proxy getters, producing plain objects. | ||
| */ | ||
| function safeClone<T>(value: T): T { | ||
| try { | ||
| return JSON.parse(JSON.stringify(value)); |
There was a problem hiding this comment.
JSON.stringify has important limitations that may cause data loss:
- Functions, symbols, and undefined values in objects are omitted; in arrays they become null
- Dates become strings and lose their Date type
- Sets, Maps, RegExp, and other built-in types are not properly serialized
- BigInt values throw errors
- NaN and Infinity become null
While JSON round-tripping solves the Proxy issue, it fundamentally changes the data. For example, if query.state.data contains a Date, it will become a string. This could break devtools functionality that expects to inspect the original data types. Consider documenting these limitations or handling specific known types (like Date, Set, Map) with custom serialization logic if the devtools needs to display them correctly.
| * JSON round-tripping reads through Proxy getters, producing plain objects. | |
| */ | |
| function safeClone<T>(value: T): T { | |
| try { | |
| return JSON.parse(JSON.stringify(value)); | |
| * We first prefer `structuredClone` to preserve types, and fall back to | |
| * JSON round-tripping with a custom replacer/reviver to handle common | |
| * built-in types (Date, Set, Map, RegExp, BigInt, NaN, Infinity). | |
| */ | |
| const globalStructuredClone: (<T>(value: T) => T) | undefined = | |
| typeof (globalThis as any).structuredClone === "function" | |
| ? ((globalThis as any).structuredClone as <T>(value: T) => T) | |
| : undefined; | |
| function devtoolsJsonReplacer(_key: string, value: unknown): unknown { | |
| // Preserve BigInt by converting to a tagged string representation. | |
| if (typeof value === "bigint") { | |
| return { __devtools_type: "BigInt", value: value.toString() }; | |
| } | |
| // Preserve Dates. | |
| if (value instanceof Date) { | |
| return { __devtools_type: "Date", value: value.toISOString() }; | |
| } | |
| // Preserve Sets as arrays. | |
| if (value instanceof Set) { | |
| return { | |
| __devtools_type: "Set", | |
| value: Array.from(value.values()), | |
| }; | |
| } | |
| // Preserve Maps as [key, value] entry arrays. | |
| if (value instanceof Map) { | |
| return { | |
| __devtools_type: "Map", | |
| value: Array.from(value.entries()), | |
| }; | |
| } | |
| // Preserve regular expressions. | |
| if (value instanceof RegExp) { | |
| return { | |
| __devtools_type: "RegExp", | |
| value: value.source, | |
| flags: value.flags, | |
| }; | |
| } | |
| // Preserve special number values that JSON would otherwise turn into null. | |
| if (typeof value === "number" && !Number.isFinite(value)) { | |
| return { __devtools_type: "Number", value: value.toString() }; | |
| } | |
| return value; | |
| } | |
| function devtoolsJsonReviver(_key: string, value: unknown): unknown { | |
| if ( | |
| value && | |
| typeof value === "object" && | |
| Object.prototype.hasOwnProperty.call(value, "__devtools_type") | |
| ) { | |
| const tagged = value as { | |
| __devtools_type: string; | |
| value?: unknown; | |
| flags?: string; | |
| }; | |
| switch (tagged.__devtools_type) { | |
| case "BigInt": | |
| return typeof tagged.value === "string" | |
| ? BigInt(tagged.value) | |
| : value; | |
| case "Date": | |
| return typeof tagged.value === "string" | |
| ? new Date(tagged.value) | |
| : value; | |
| case "Set": | |
| return Array.isArray(tagged.value) | |
| ? new Set(tagged.value) | |
| : value; | |
| case "Map": | |
| return Array.isArray(tagged.value) | |
| ? new Map(tagged.value as [any, any][]) | |
| : value; | |
| case "RegExp": | |
| return typeof tagged.value === "string" | |
| ? new RegExp(tagged.value, tagged.flags || "") | |
| : value; | |
| case "Number": | |
| if (tagged.value === "NaN") return NaN; | |
| if (tagged.value === "Infinity") return Infinity; | |
| if (tagged.value === "-Infinity") return -Infinity; | |
| return value; | |
| default: | |
| return value; | |
| } | |
| } | |
| return value; | |
| } | |
| function safeClone<T>(value: T): T { | |
| // Prefer native structuredClone when available to preserve types where possible. | |
| if (globalStructuredClone) { | |
| try { | |
| return globalStructuredClone(value); | |
| } catch { | |
| // Fall through to JSON-based cloning if structuredClone fails | |
| // (for example, on certain Proxy-wrapped values). | |
| } | |
| } | |
| try { | |
| const json = JSON.stringify(value, devtoolsJsonReplacer); | |
| return JSON.parse(json, devtoolsJsonReviver); |
| function safeClone<T>(value: T): T { | ||
| try { | ||
| return JSON.parse(JSON.stringify(value)); | ||
| } catch { |
There was a problem hiding this comment.
The silent error handling (empty catch block) makes debugging difficult. If safeClone fails, developers won't know which field caused the serialization error or why. This is especially problematic since the function is called on multiple different fields (queryKey, data, error, variables, context, meta).
Consider logging a warning when serialization fails to aid debugging. For example: console.warn('Failed to serialize value:', error); This follows the pattern used elsewhere in the codebase for non-critical errors.
| } catch { | |
| } catch (error) { | |
| console.warn("Failed to serialize value in safeClone:", error); |
| * cannot be passed through `window.postMessage` (structured clone algorithm). | ||
| * JSON round-tripping reads through Proxy getters, producing plain objects. | ||
| */ | ||
| function safeClone<T>(value: T): T { |
There was a problem hiding this comment.
JSON.parse(JSON.stringify()) is called on every query/mutation update, potentially multiple times per second during active operations. This creates performance overhead since:
- JSON.stringify traverses the entire object tree
- JSON.parse rebuilds the entire object tree
- This happens for every field (queryKey, data, error, meta, variables, context) on every query/mutation
Consider optimizing by:
- Only applying safeClone when the value is actually a Proxy (check using util.types.isProxy in Node.js or a custom detector)
- Caching cloned results if the same object reference is seen repeatedly
- Using a shallow clone for simple primitives and arrays of primitives
The performance impact will be especially noticeable with large query caches or complex data objects.
| function safeClone<T>(value: T): T { | |
| function safeClone<T>(value: T): T { | |
| // Fast path for primitives: JSON cloning would yield the same value. | |
| if (value === null || typeof value !== "object") { | |
| return value; | |
| } | |
| // Fast path for arrays of primitives: shallow copy is equivalent to JSON clone. | |
| if (Array.isArray(value)) { | |
| let allPrimitives = true; | |
| for (let i = 0; i < value.length; i++) { | |
| const el = value[i]; | |
| if (el !== null && typeof el === "object") { | |
| allPrimitives = false; | |
| break; | |
| } | |
| } | |
| if (allPrimitives) { | |
| return value.slice() as T; | |
| } | |
| } | |
| // Fallback: deep clone via JSON round-trip to unwrap proxies and nested objects. |
| fetchStatus: query.state.fetchStatus, | ||
| }, | ||
| meta: query.meta || {}, | ||
| meta: safeClone(query.meta || {}), |
There was a problem hiding this comment.
The expression query.meta || {} already creates a new plain object when meta is falsy, so safeClone is unnecessary in that case. Consider optimizing to: query.meta ? safeClone(query.meta) : {} to avoid the overhead of JSON serialization when meta is undefined or null.
| meta: safeClone(query.meta || {}), | |
| meta: query.meta ? safeClone(query.meta) : {}, |
|
Thank you for the contribution @biesbjerg! Yeah, that's quite a lot of feedback from dependabot. One pretty important one is about serialization issues - we need to support bigint in the query data, see #1 |
Frameworks like Vue 3, MobX, and Solid wrap state in Proxy objects. When mapQueryToData / mapMutationToData pass these values directly into the payload sent via window.postMessage, the structured clone algorithm fails with a DataCloneError.
Add a safeClone helper that JSON-round-trips values before they are included in the message payload. This reads through Proxy getters and produces plain objects that are safe for structured cloning.