Skip to content

Commit 6ad4437

Browse files
authored
fix(offline-transactions): harden value serialization (#1873)
* fix(offline-transactions): harden value serialization * fix(offline-transactions): preserve plain tagged values * test(offline-transactions): mark partial mutation fixture * fix(offline-transactions): bound toJSON replacement cycles
1 parent febd4bc commit 6ad4437

3 files changed

Lines changed: 305 additions & 36 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/offline-transactions': patch
3+
---
4+
5+
Reject cyclic offline transaction values with a bounded error, preserve user objects that only imitate Temporal tags, and avoid allocating an index list for every serialized array.

packages/offline-transactions/src/outbox/TransactionSerializer.ts

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ const temporalConstructorNames = [
1818
] as const
1919

2020
type TemporalConstructorName = (typeof temporalConstructorNames)[number]
21-
type TemporalConstructor = { from: (value: string) => unknown }
21+
type TemporalConstructor = {
22+
from: (value: string) => unknown
23+
prototype?: { toString?: () => string }
24+
}
2225

2326
function getTemporalConstructorName(
2427
type: unknown,
@@ -47,6 +50,33 @@ function requireTemporalConstructor(
4750
return constructor
4851
}
4952

53+
function serializeTemporalValue(
54+
value: object,
55+
name: TemporalConstructorName,
56+
): string | undefined {
57+
const constructor = requireTemporalConstructor(name)
58+
const toString = constructor.prototype?.toString
59+
if (typeof toString !== `function` || toString === Object.prototype.toString)
60+
return
61+
try {
62+
const serialized = toString.call(value)
63+
return typeof serialized === `string` ? serialized : undefined
64+
} catch {
65+
// Temporal prototype methods brand-check their receiver. A matching
66+
// Symbol.toStringTag without the corresponding internal slots is user data.
67+
return
68+
}
69+
}
70+
71+
function mayBeNativeScalar(value: object): boolean {
72+
const prototype = Object.getPrototypeOf(value)
73+
return (
74+
prototype !== null &&
75+
prototype !== Object.prototype &&
76+
!Array.isArray(value)
77+
)
78+
}
79+
5080
export class MissingTemporalConstructorError extends Error {}
5181

5282
function setDataProperty(
@@ -184,29 +214,51 @@ export class TransactionSerializer {
184214
} as PendingMutation
185215
}
186216

187-
private serializeValue(value: any, jsonKey?: string | false): any {
217+
private serializeValue(
218+
value: any,
219+
jsonKey?: string | false,
220+
ancestors = new WeakSet<object>(),
221+
): any {
188222
if (value === null || typeof value !== `object`) return value
189223

190224
if (jsonKey !== false && value instanceof Date) {
191225
return { __type: `Date`, value: value.toISOString() }
192226
}
193227

194228
const temporalConstructorName =
195-
jsonKey !== false
229+
jsonKey !== false && mayBeNativeScalar(value)
196230
? getTemporalConstructorName(value[Symbol.toStringTag])
197231
: undefined
198232
if (temporalConstructorName) {
199-
requireTemporalConstructor(temporalConstructorName)
200-
return {
201-
__type: `Temporal`,
202-
type: `Temporal.${temporalConstructorName}`,
203-
value: value.toString(),
204-
}
233+
const temporalValue = serializeTemporalValue(
234+
value,
235+
temporalConstructorName,
236+
)
237+
if (temporalValue !== undefined)
238+
return {
239+
__type: `Temporal`,
240+
type: `Temporal.${temporalConstructorName}`,
241+
value: temporalValue,
242+
}
205243
}
206244

245+
if (ancestors.has(value))
246+
throw new TypeError(`Converting circular structure to JSON`)
247+
207248
const toJSON = typeof jsonKey === `string` && value.toJSON
208-
if (typeof toJSON === `function`)
209-
return this.serializeValue(toJSON.call(value, jsonKey), false)
249+
if (typeof toJSON === `function`) {
250+
const replacement = toJSON.call(value, jsonKey)
251+
if (replacement === value) {
252+
return this.serializeValue(replacement, false, ancestors)
253+
}
254+
255+
ancestors.add(value)
256+
try {
257+
return this.serializeValue(replacement, false, ancestors)
258+
} finally {
259+
ancestors.delete(value)
260+
}
261+
}
210262
if (
211263
jsonKey !== undefined &&
212264
(value instanceof Boolean ||
@@ -216,20 +268,41 @@ export class TransactionSerializer {
216268
) {
217269
return value.valueOf()
218270
}
271+
272+
ancestors.add(value)
273+
219274
const isArray = Array.isArray(value)
220275
const result: any = isArray ? [] : {}
221-
const keys = isArray
222-
? Array.from({ length: value.length }, (_, index) => String(index))
223-
: Object.keys(value)
224-
for (const key of keys) {
225-
setDataProperty(
226-
result,
227-
key,
228-
this.serializeValue(
229-
value[key],
230-
jsonKey === undefined ? undefined : key,
231-
),
232-
)
276+
try {
277+
if (isArray) {
278+
const length = value.length
279+
for (let index = 0; index < length; index++) {
280+
const key = String(index)
281+
setDataProperty(
282+
result,
283+
key,
284+
this.serializeValue(
285+
value[index],
286+
jsonKey === undefined ? undefined : key,
287+
ancestors,
288+
),
289+
)
290+
}
291+
} else {
292+
for (const key of Object.keys(value)) {
293+
setDataProperty(
294+
result,
295+
key,
296+
this.serializeValue(
297+
value[key],
298+
jsonKey === undefined ? undefined : key,
299+
ancestors,
300+
),
301+
)
302+
}
303+
}
304+
} finally {
305+
ancestors.delete(value)
233306
}
234307
if (jsonKey === false && typeof result.toJSON === `function`)
235308
delete result.toJSON

0 commit comments

Comments
 (0)