Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-offline-serializer-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/offline-transactions': patch
---

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.
119 changes: 96 additions & 23 deletions packages/offline-transactions/src/outbox/TransactionSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ const temporalConstructorNames = [
] as const

type TemporalConstructorName = (typeof temporalConstructorNames)[number]
type TemporalConstructor = { from: (value: string) => unknown }
type TemporalConstructor = {
from: (value: string) => unknown
prototype?: { toString?: () => string }
}

function getTemporalConstructorName(
type: unknown,
Expand Down Expand Up @@ -47,6 +50,33 @@ function requireTemporalConstructor(
return constructor
}

function serializeTemporalValue(
value: object,
name: TemporalConstructorName,
): string | undefined {
const constructor = requireTemporalConstructor(name)
const toString = constructor.prototype?.toString
if (typeof toString !== `function` || toString === Object.prototype.toString)
return
try {
const serialized = toString.call(value)
return typeof serialized === `string` ? serialized : undefined
} catch {
// Temporal prototype methods brand-check their receiver. A matching
// Symbol.toStringTag without the corresponding internal slots is user data.
return
}
}

function mayBeNativeScalar(value: object): boolean {
const prototype = Object.getPrototypeOf(value)
return (
prototype !== null &&
prototype !== Object.prototype &&
!Array.isArray(value)
)
}

export class MissingTemporalConstructorError extends Error {}

function setDataProperty(
Expand Down Expand Up @@ -184,29 +214,51 @@ export class TransactionSerializer {
} as PendingMutation
}

private serializeValue(value: any, jsonKey?: string | false): any {
private serializeValue(
value: any,
jsonKey?: string | false,
ancestors = new WeakSet<object>(),
): any {
if (value === null || typeof value !== `object`) return value

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

const temporalConstructorName =
jsonKey !== false
jsonKey !== false && mayBeNativeScalar(value)
? getTemporalConstructorName(value[Symbol.toStringTag])
: undefined
if (temporalConstructorName) {
requireTemporalConstructor(temporalConstructorName)
return {
__type: `Temporal`,
type: `Temporal.${temporalConstructorName}`,
value: value.toString(),
}
const temporalValue = serializeTemporalValue(
value,
temporalConstructorName,
)
if (temporalValue !== undefined)
return {
__type: `Temporal`,
type: `Temporal.${temporalConstructorName}`,
value: temporalValue,
}
}

if (ancestors.has(value))
throw new TypeError(`Converting circular structure to JSON`)

const toJSON = typeof jsonKey === `string` && value.toJSON
if (typeof toJSON === `function`)
return this.serializeValue(toJSON.call(value, jsonKey), false)
if (typeof toJSON === `function`) {
const replacement = toJSON.call(value, jsonKey)
if (replacement === value) {
return this.serializeValue(replacement, false, ancestors)
}

ancestors.add(value)
try {
return this.serializeValue(replacement, false, ancestors)
} finally {
ancestors.delete(value)
}
}
if (
jsonKey !== undefined &&
(value instanceof Boolean ||
Expand All @@ -216,20 +268,41 @@ export class TransactionSerializer {
) {
return value.valueOf()
}

ancestors.add(value)

const isArray = Array.isArray(value)
const result: any = isArray ? [] : {}
const keys = isArray
? Array.from({ length: value.length }, (_, index) => String(index))
: Object.keys(value)
for (const key of keys) {
setDataProperty(
result,
key,
this.serializeValue(
value[key],
jsonKey === undefined ? undefined : key,
),
)
try {
if (isArray) {
const length = value.length
for (let index = 0; index < length; index++) {
const key = String(index)
setDataProperty(
result,
key,
this.serializeValue(
value[index],
jsonKey === undefined ? undefined : key,
ancestors,
),
)
}
} else {
for (const key of Object.keys(value)) {
setDataProperty(
result,
key,
this.serializeValue(
value[key],
jsonKey === undefined ? undefined : key,
ancestors,
),
)
}
}
} finally {
ancestors.delete(value)
}
if (jsonKey === false && typeof result.toJSON === `function`)
delete result.toJSON
Expand Down
Loading
Loading