// Copyright 2025-present 650 Industries. All rights reserved. import ExpoModulesJSI /** Encodes `Encodable` values to `JavaScriptValue`. For any property whose static type is known to the dynamic-type registry (anything routed via the `~` operator: arrays, dictionaries, optionals, `RawRepresentable` enums, `Convertible`s, `JavaScriptValue` and so on), the encoder takes the fast path through `castToJS`, preserving full element-type metadata. Only types the registry doesn't recognize (i.e. plain `Encodable` structs and classes) fall back to Swift's `Encodable` machinery via `value.encode(to:)`. */ internal final class JSValueEncoder: Encoder { private let appContext: AppContext private let runtime: JavaScriptRuntime private let valueHolder: JSValueHolder /** The result of encoding to `JavaScriptValue`. Use this property after running `encode(to:)` on the encodable. */ var value: JavaScriptValue { return valueHolder.value } /** Initializes the encoder with the given app context. Throws if the runtime has been lost. */ convenience init(appContext: AppContext) throws { try self.init(appContext: appContext, runtime: appContext.runtime) } /** Initializes the encoder with the given app context and an explicit runtime. Use this when produced JS values must live in a runtime other than the one currently held by the app context. */ convenience init(appContext: AppContext, runtime: JavaScriptRuntime) { self.init( appContext: appContext, runtime: runtime, codingPath: [], valueHolder: JSValueHolder() ) } fileprivate init( appContext: AppContext, runtime: JavaScriptRuntime, codingPath: [any CodingKey], valueHolder: JSValueHolder ) { self.appContext = appContext self.runtime = runtime self.codingPath = codingPath self.valueHolder = valueHolder } // MARK: - Encoder let codingPath: [any CodingKey] let userInfo: [CodingUserInfoKey: Any] = [:] func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { let container = JSObjectEncodingContainer( to: valueHolder, appContext: appContext, runtime: runtime, codingPath: codingPath ) return KeyedEncodingContainer(container) } func unkeyedContainer() -> any UnkeyedEncodingContainer { return JSArrayEncodingContainer( to: valueHolder, appContext: appContext, runtime: runtime, codingPath: codingPath ) } func singleValueContainer() -> any SingleValueEncodingContainer { return JSValueEncodingContainer( to: valueHolder, appContext: appContext, runtime: runtime, codingPath: codingPath ) } } // MARK: - Shared encoding logic /** Encodes a single value into a `JavaScriptValue`, routing through the dynamic-type registry whenever possible and falling back to `Encodable` only for plain types. */ private func encodeUsingDynamicType( _ value: ValueType, appContext: AppContext, runtime: JavaScriptRuntime, codingPath: [any CodingKey] ) throws -> JavaScriptValue { let dynamicType = ~ValueType.self if !(dynamicType is DynamicCodableType) { return try dynamicType.castToJS(value, appContext: appContext, in: runtime) } // Plain Encodable type the registry doesn't recognize — recurse via Encodable. let holder = JSValueHolder() let encoder = JSValueEncoder( appContext: appContext, runtime: runtime, codingPath: codingPath, valueHolder: holder ) try value.encode(to: encoder) return holder.value } // MARK: - Containers /** An object that holds a JS value, mutated by an encoding container as it makes progress. */ private final class JSValueHolder { var value: JavaScriptValue = .undefined } /** Single value container used to encode primitive values, including optionals. */ private struct JSValueEncodingContainer: SingleValueEncodingContainer { private let appContext: AppContext private let runtime: JavaScriptRuntime private let valueHolder: JSValueHolder let codingPath: [any CodingKey] init(to valueHolder: JSValueHolder, appContext: AppContext, runtime: JavaScriptRuntime, codingPath: [any CodingKey]) { self.valueHolder = valueHolder self.appContext = appContext self.runtime = runtime self.codingPath = codingPath } mutating func encodeNil() throws { valueHolder.value = .null } mutating func encode(_ value: ValueType) throws { valueHolder.value = try encodeUsingDynamicType( value, appContext: appContext, runtime: runtime, codingPath: codingPath ) } } /** Keyed container that encodes to a JavaScript object. - Note: This is a `class` (not a `struct`) because it holds a non-copyable `JavaScriptObject`. A struct property of a non-copyable type makes the containing struct non-copyable too, which conflicts with `KeyedEncodingContainerProtocol`'s expectation of a copyable conformer. Using a class sidesteps the constraint via reference semantics. */ private final class JSObjectEncodingContainer: KeyedEncodingContainerProtocol { private let appContext: AppContext private let runtime: JavaScriptRuntime private let valueHolder: JSValueHolder private let object: JavaScriptObject let codingPath: [any CodingKey] init(to valueHolder: JSValueHolder, appContext: AppContext, runtime: JavaScriptRuntime, codingPath: [any CodingKey]) { let object = runtime.createObject() valueHolder.value = object.asValue() self.appContext = appContext self.runtime = runtime self.valueHolder = valueHolder self.object = object self.codingPath = codingPath } func encodeNil(forKey key: Key) throws { object.setProperty(key.stringValue, value: JavaScriptValue.null) } func encode(_ value: ValueType, forKey key: Key) throws { let encoded = try encodeUsingDynamicType( value, appContext: appContext, runtime: runtime, codingPath: codingPath + [key] ) object.setProperty(key.stringValue, value: encoded) } // The default `KeyedEncodingContainerProtocol.encodeIfPresent` does nothing for // nil values, which leaves the JS object without the key entirely — so `'label' in obj` // returns false and consumers can't distinguish "absent" from "explicitly null". // Override every overload (one per primitive plus a generic `Encodable` one) so nil // optional fields produce an explicit `null` instead. func encodeIfPresent(_ value: ValueType?, forKey key: Key) throws { if let value { try encode(value, forKey: key) } else { try encodeNil(forKey: key) } } func encodeIfPresent(_ value: Bool?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: String?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: Double?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: Float?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: Int?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: Int8?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: Int16?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: Int32?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: Int64?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: UInt?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: UInt8?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: UInt16?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: UInt32?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } func encodeIfPresent(_ value: UInt64?, forKey key: Key) throws { try encodeOptional(value, forKey: key) } private func encodeOptional(_ value: ValueType?, forKey key: Key) throws { if let value { try encode(value, forKey: key) } else { try encodeNil(forKey: key) } } func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { let holder = JSValueHolder() let container = JSObjectEncodingContainer( to: holder, appContext: appContext, runtime: runtime, codingPath: codingPath + [key] ) object.setProperty(key.stringValue, value: holder.value) return KeyedEncodingContainer(container) } func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer { let holder = JSValueHolder() let container = JSArrayEncodingContainer( to: holder, appContext: appContext, runtime: runtime, codingPath: codingPath + [key] ) object.setProperty(key.stringValue, value: holder.value) return container } func superEncoder() -> any Encoder { fatalError("JSValueEncoder does not support superEncoder()") } func superEncoder(forKey key: Key) -> any Encoder { fatalError("JSValueEncoder does not support superEncoder(forKey:)") } } /** Unkeyed container that encodes values to a JavaScript array. Like `JSObjectEncodingContainer`, this is a `class` so it can hold the non-copyable `JavaScriptArray` directly without forcing the container itself to be non-copyable. */ private final class JSArrayEncodingContainer: UnkeyedEncodingContainer { private let appContext: AppContext private let runtime: JavaScriptRuntime private let valueHolder: JSValueHolder private let array: JavaScriptArray let codingPath: [any CodingKey] var count: Int = 0 init(to valueHolder: JSValueHolder, appContext: AppContext, runtime: JavaScriptRuntime, codingPath: [any CodingKey]) { let array = runtime.createArray() valueHolder.value = array.asValue() self.appContext = appContext self.runtime = runtime self.valueHolder = valueHolder self.array = array self.codingPath = codingPath } func encodeNil() throws { array[count] = JavaScriptValue.null count += 1 } func encode(_ value: ValueType) throws { let encoded = try encodeUsingDynamicType( value, appContext: appContext, runtime: runtime, codingPath: codingPath + [AnyCodingKey(intValue: count)] ) array[count] = encoded count += 1 } func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer { let holder = JSValueHolder() let container = JSObjectEncodingContainer( to: holder, appContext: appContext, runtime: runtime, codingPath: codingPath + [AnyCodingKey(intValue: count)] ) array[count] = holder.value count += 1 return KeyedEncodingContainer(container) } func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { let holder = JSValueHolder() let container = JSArrayEncodingContainer( to: holder, appContext: appContext, runtime: runtime, codingPath: codingPath + [AnyCodingKey(intValue: count)] ) array[count] = holder.value count += 1 return container } func superEncoder() -> any Encoder { fatalError("JSValueEncoder does not support superEncoder()") } }