From efb9b628881e42012b76bc649bbad1ab5ca9db30 Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Thu, 12 Nov 2026 05:04:57 -0800 Subject: [PATCH] Add native token-stream writer and readable maps (#59627) Summary: Add paired native writer and typed Java readers for structured data transfers without a JavaScript runtime. One document shares its name/value string table across values, with stable ranges and independent out-of-order map and array access. The writer accepts `folly::dynamic` by reference, validates Unicode, preserves native numbers and rolls back failed appends. The reader uses an indexed shared context without eagerly materialising values. Both support explicit ownership transfer, and generated C++ API snapshots accompany the native API. Mounting transport integration remains separate. Changelog: [General][Added] - Add native token-stream serialization and typed readable maps Differential Revision: D128313426 --- .../facebook/react/bridge/TokenStreamMap.kt | 470 +++++++++++ .../react/bridge/TokenStreamMapTest.kt | 545 +++++++++++++ .../ReactCommon/react/utils/CMakeLists.txt | 2 + .../ReactCommon/react/utils/React/Utils.h | 1 + .../react/utils/TokenStreamWriter.cpp | 292 +++++++ .../react/utils/TokenStreamWriter.h | 105 +++ .../utils/tests/TokenStreamWriterTest.cpp | 753 ++++++++++++++++++ .../api-snapshots/ReactAndroidDebugCxx.api | 24 + .../ReactAndroidFrameworksCxx.api | 24 + .../api-snapshots/ReactAndroidNewarchCxx.api | 24 + .../api-snapshots/ReactAndroidReleaseCxx.api | 24 + .../api-snapshots/ReactAppleDebugCxx.api | 24 + .../api-snapshots/ReactAppleFrameworksCxx.api | 24 + .../api-snapshots/ReactAppleNewarchCxx.api | 24 + .../api-snapshots/ReactAppleReleaseCxx.api | 24 + .../api-snapshots/ReactCommonDebugCxx.api | 24 + .../ReactCommonFrameworksCxx.api | 24 + .../api-snapshots/ReactCommonNewarchCxx.api | 24 + .../api-snapshots/ReactCommonReleaseCxx.api | 24 + 19 files changed, 2456 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/TokenStreamMap.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/TokenStreamMapTest.kt create mode 100644 packages/react-native/ReactCommon/react/utils/TokenStreamWriter.cpp create mode 100644 packages/react-native/ReactCommon/react/utils/TokenStreamWriter.h create mode 100644 packages/react-native/ReactCommon/react/utils/tests/TokenStreamWriterTest.cpp diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/TokenStreamMap.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/TokenStreamMap.kt new file mode 100644 index 000000000000..eb6bfcdb04de --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/TokenStreamMap.kt @@ -0,0 +1,470 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.bridge + +import com.facebook.react.common.annotations.UnstableReactNativeAPI + +/** + * Owns a version-one binary token stream document and its shared string table. Container and string + * offsets are indexed before any maps are exposed, so reading one map never advances another map's + * decoder. The constructor copies the input; [fromOwnedBytes] accepts ownership without copying. + * + * This reader supports ordinary values, not the embedded-document tag. A mounting batch should + * encode its props as objects in one document, sharing the string table across those objects. + * Malformed documents throw [IllegalArgumentException] during construction. + */ +@UnstableReactNativeAPI +public class TokenStreamContext private constructor(bytes: ByteArray, copy: Boolean) { + /** Copies the document so later changes to the caller's buffer cannot affect retained maps. */ + public constructor(bytes: ByteArray) : this(bytes, true) + + private val bytes = if (copy) bytes.copyOf() else bytes + private val containers = HashMap() + private val stringIds = HashMap() + private val strings = ArrayList() + private val numbers = HashMap() + + init { + require( + this.bytes.size >= 4 && + this.bytes[0] == 0x42.toByte() && + this.bytes[1] == 0x54.toByte() && + this.bytes[2] == 0x53.toByte() && + this.bytes[3] == 1.toByte(), + ) { + "Not a BTS v1 document" + } + indexDocument() + } + + /** Returns an object at a previously indexed token offset, or the root object by default. */ + public fun getMap(offset: Int = 4): TokenStreamMap { + require(containerAt(offset).tag == START_OBJECT) { "No object at offset $offset" } + return TokenStreamMap(this, offset) + } + + /** Returns an array at a previously indexed token offset, or the root array by default. */ + public fun getArray(offset: Int = 4): ReadableArray { + require(containerAt(offset).tag == START_ARRAY) { "No array at offset $offset" } + return TokenStreamArray(this, containerAt(offset).values) + } + + internal inner class Container(val tag: Int, val names: IntArray, val values: IntArray) { + val fields: Map by lazy { + LinkedHashMap().apply { + for (i in names.indices) put(stringAt(names[i]), this@Container.values[i]) + } + } + } + + private class StringSlice(val offset: Int, val length: Int) { + @Volatile var decoded: String? = null + } + + private class Frame(val offset: Int, val tag: Int) { + val names = ArrayList() + val values = ArrayList() + var name = -1 + } + + internal fun containerAt(offset: Int): Container = + containers[offset] ?: throw IllegalArgumentException("No container at offset $offset") + + internal fun stringAt(id: Int): String { + val slice = strings[id] + slice.decoded?.let { + return it + } + return synchronized(slice) { + slice.decoded + ?: String(bytes, slice.offset, slice.length, Charsets.UTF_8).also { slice.decoded = it } + } + } + + internal fun typeAt(offset: Int): ReadableType = + when (bytes[offset].toInt()) { + START_OBJECT -> ReadableType.Map + START_ARRAY -> ReadableType.Array + NULL -> ReadableType.Null + FALSE, + TRUE -> ReadableType.Boolean + INTEGER, + NUMBER -> ReadableType.Number + STRING, + STRING_REF -> ReadableType.String + else -> error("Not a value at offset $offset") + } + + internal fun booleanAt(offset: Int): Boolean { + requireType(offset, ReadableType.Boolean) + return bytes[offset].toInt() == TRUE + } + + internal fun doubleAt(offset: Int): Double { + requireType(offset, ReadableType.Number) + return if (bytes[offset].toInt() == INTEGER) Cursor(offset + 1).integer().toDouble() + else checkNotNull(numbers[offset]) + } + + internal fun longAt(offset: Int): Long { + requireType(offset, ReadableType.Number) + return if (bytes[offset].toInt() == INTEGER) Cursor(offset + 1).integer() + else doubleAt(offset).toLong() + } + + internal fun nullableStringAt(offset: Int): String? { + if (typeAt(offset) == ReadableType.Null) return null + requireType(offset, ReadableType.String) + return stringAt(checkNotNull(stringIds[offset])) + } + + internal fun nullableMapAt(offset: Int): ReadableMap? { + if (typeAt(offset) == ReadableType.Null) return null + requireType(offset, ReadableType.Map) + return getMap(offset) + } + + internal fun nullableArrayAt(offset: Int): ReadableArray? { + if (typeAt(offset) == ReadableType.Null) return null + requireType(offset, ReadableType.Array) + return getArray(offset) + } + + internal fun valueAt(offset: Int): Any? = + when (typeAt(offset)) { + ReadableType.Null -> null + ReadableType.Boolean -> booleanAt(offset) + ReadableType.Number -> doubleAt(offset) + ReadableType.String -> nullableStringAt(offset) + ReadableType.Map -> nullableMapAt(offset) + ReadableType.Array -> nullableArrayAt(offset) + } + + private class MaterializationFrame( + val container: Container, + val map: HashMap?, + val array: ArrayList?, + ) { + var index = 0 + } + + private fun materializationFrame(offset: Int): MaterializationFrame { + val container = containerAt(offset) + return if (container.tag == START_OBJECT) { + MaterializationFrame(container, HashMap(), null) + } else { + MaterializationFrame(container, null, ArrayList(container.values.size)) + } + } + + internal fun materializedValueAt(offset: Int): Any? { + if (typeAt(offset) != ReadableType.Map && typeAt(offset) != ReadableType.Array) { + return valueAt(offset) + } + val root = materializationFrame(offset) + val stack = ArrayDeque() + stack.addLast(root) + while (stack.isNotEmpty()) { + val frame = stack.last() + if (frame.index == frame.container.values.size) { + stack.removeLast() + continue + } + val index = frame.index++ + val childOffset = frame.container.values[index] + val type = typeAt(childOffset) + val child = + if (type == ReadableType.Map || type == ReadableType.Array) { + materializationFrame(childOffset) + } else { + null + } + val value = if (child != null) child.map ?: child.array else valueAt(childOffset) + if (frame.map != null) { + frame.map[stringAt(frame.container.names[index])] = value + } else { + checkNotNull(frame.array).add(value) + } + if (child != null) stack.addLast(child) + } + return root.map ?: root.array + } + + private fun requireType(offset: Int, expected: ReadableType) { + val actual = typeAt(offset) + if (actual != expected) { + throw UnexpectedNativeTypeException("Expected $expected, got $actual") + } + } + + private inner class Cursor(var position: Int) { + fun byte(): Int { + require(position < bytes.size) { "Truncated BTS document" } + return bytes[position++].toInt() and 0xff + } + + fun varint(): Long { + var value = 0L + for (shift in 0..63 step 7) { + val byte = byte() + require(shift != 63 || byte <= 1) { "Overflowing BTS varint" } + value = value or ((byte and 0x7f).toLong() shl shift) + if (byte and 0x80 == 0) return value + } + throw IllegalArgumentException("Malformed BTS varint") + } + + fun integer(): Long { + val zigzag = varint() + return (zigzag ushr 1) xor -(zigzag and 1) + } + + fun length(): Int { + val length = varint() + require(length >= 0 && length <= bytes.size - position) { "Invalid BTS string length" } + return length.toInt() + } + + fun stringId(tag: Int): Int { + if (tag == STRING_REF || tag == NAME_REF) { + val id = varint() + require(id >= 0 && id < strings.size) { "Invalid BTS string reference" } + return id.toInt() + } + val length = length() + validateUtf8(position, length) + strings.add(StringSlice(position, length)) + position += length + return strings.lastIndex + } + } + + private fun indexDocument() { + val cursor = Cursor(4) + val stack = ArrayDeque() + var hasRoot = false + + fun recordValue(offset: Int) { + val parent = stack.lastOrNull() + if (parent == null) { + require(!hasRoot) { "Multiple BTS root values" } + hasRoot = true + } else { + if (parent.tag == START_OBJECT) { + require(parent.name != -1) { "BTS object value has no name" } + parent.names.add(parent.name) + parent.name = -1 + } + parent.values.add(offset) + } + } + + while (cursor.position < bytes.size) { + val offset = cursor.position + when (val tag = cursor.byte()) { + START_OBJECT, + START_ARRAY -> { + recordValue(offset) + stack.addLast(Frame(offset, tag)) + } + END_OBJECT, + END_ARRAY -> { + val frame = stack.removeLastOrNull() + require(frame != null && frame.tag == tag - 1 && frame.name == -1) { + "Unbalanced BTS container" + } + containers[frame.offset] = + Container(frame.tag, frame.names.toIntArray(), frame.values.toIntArray()) + } + NAME, + NAME_REF -> { + val frame = stack.lastOrNull() + require(frame != null && frame.tag == START_OBJECT && frame.name == -1) { + "Unexpected BTS property name" + } + frame.name = cursor.stringId(tag) + } + STRING, + STRING_REF -> { + recordValue(offset) + stringIds[offset] = cursor.stringId(tag) + } + INTEGER -> { + recordValue(offset) + cursor.varint() + } + NUMBER -> { + recordValue(offset) + val length = cursor.length() + val text = String(bytes, cursor.position, length, Charsets.US_ASCII) + numbers[offset] = + text.toDoubleOrNull() + ?: throw IllegalArgumentException("Invalid number in token stream") + cursor.position += length + } + NULL, + FALSE, + TRUE -> recordValue(offset) + else -> throw IllegalArgumentException("Unsupported BTS tag $tag") + } + } + require(hasRoot && stack.isEmpty()) { "Incomplete BTS document" } + } + + private fun validateUtf8(offset: Int, length: Int) { + var position = offset + val end = offset + length + while (position < end) { + val lead = bytes[position++].toInt() and 0xff + if (lead < 0x80) continue + val continuationCount = + when (lead) { + in 0xc2..0xdf -> 1 + in 0xe0..0xef -> 2 + in 0xf0..0xf4 -> 3 + else -> throw IllegalArgumentException("Invalid BTS UTF-8") + } + require(continuationCount <= end - position) { "Truncated BTS UTF-8" } + val second = bytes[position].toInt() and 0xff + require( + when (lead) { + 0xe0 -> second in 0xa0..0xbf + 0xed -> second in 0x80..0x9f + 0xf0 -> second in 0x90..0xbf + 0xf4 -> second in 0x80..0x8f + else -> second in 0x80..0xbf + }, + ) { + "Invalid BTS UTF-8" + } + repeat(continuationCount) { + require(bytes[position++].toInt() and 0xc0 == 0x80) { "Invalid BTS UTF-8" } + } + } + } + + @UnstableReactNativeAPI + public companion object { + /** + * Takes ownership of a document without copying its bytes. The caller must not modify the + * buffer after this call. Maps and arrays keep the buffer alive through their shared context. + */ + @JvmStatic + public fun fromOwnedBytes(bytes: ByteArray): TokenStreamContext = + TokenStreamContext(bytes, false) + + private const val START_OBJECT = 1 + private const val END_OBJECT = 2 + private const val START_ARRAY = 3 + private const val END_ARRAY = 4 + private const val NULL = 5 + private const val FALSE = 6 + private const val TRUE = 7 + private const val INTEGER = 8 + private const val NUMBER = 9 + private const val STRING = 10 + private const val STRING_REF = 11 + private const val NAME = 12 + private const val NAME_REF = 13 + } +} + +/** + * A read-only object view into a [TokenStreamContext]. Maps share decoded strings but not read + * cursors or iteration state. Missing keys throw [NoSuchKeyException]; explicit nulls remain + * distinguishable from absent keys. Native-map-only operations such as [WritableNativeMap.merge] + * require materializing this map into a compatible native map first. + */ +@UnstableReactNativeAPI +public class TokenStreamMap +internal constructor( + private val context: TokenStreamContext, + offset: Int, +) : ReadableMap { + private val container = context.containerAt(offset) + private val fields: Map + get() = container.fields + + private fun offset(name: String): Int = fields[name] ?: throw NoSuchKeyException(name) + + override fun hasKey(name: String): Boolean = fields.containsKey(name) + + override fun isNull(name: String): Boolean = getType(name) == ReadableType.Null + + override fun getType(name: String): ReadableType = context.typeAt(offset(name)) + + override fun getBoolean(name: String): Boolean = context.booleanAt(offset(name)) + + override fun getDouble(name: String): Double = context.doubleAt(offset(name)) + + override fun getInt(name: String): Int = getDouble(name).toInt() + + override fun getLong(name: String): Long = context.longAt(offset(name)) + + override fun getString(name: String): String? = context.nullableStringAt(offset(name)) + + override fun getMap(name: String): ReadableMap? = context.nullableMapAt(offset(name)) + + override fun getArray(name: String): ReadableArray? = context.nullableArrayAt(offset(name)) + + override fun getDynamic(name: String): Dynamic = DynamicFromMap.create(this, name) + + override fun keySetIterator(): ReadableMapKeySetIterator { + val iterator = fields.keys.iterator() + return object : ReadableMapKeySetIterator { + override fun hasNextKey(): Boolean = iterator.hasNext() + + override fun nextKey(): String = iterator.next() + } + } + + override val entryIterator: Iterator> + get() = + fields.entries + .asSequence() + .map { java.util.AbstractMap.SimpleImmutableEntry(it.key, context.valueAt(it.value)) } + .iterator() + + override fun toHashMap(): HashMap = + HashMap().apply { + for ((name, offset) in fields) put(name, context.materializedValueAt(offset)) + } +} + +@OptIn(UnstableReactNativeAPI::class) +private class TokenStreamArray( + private val context: TokenStreamContext, + private val offsets: IntArray, +) : ReadableArray { + override fun size(): Int = offsets.size + + override fun isNull(index: Int): Boolean = getType(index) == ReadableType.Null + + override fun getType(index: Int): ReadableType = context.typeAt(offsets[index]) + + override fun getBoolean(index: Int): Boolean = context.booleanAt(offsets[index]) + + override fun getDouble(index: Int): Double = context.doubleAt(offsets[index]) + + override fun getInt(index: Int): Int = getDouble(index).toInt() + + override fun getLong(index: Int): Long = context.longAt(offsets[index]) + + override fun getString(index: Int): String? = context.nullableStringAt(offsets[index]) + + override fun getMap(index: Int): ReadableMap? = context.nullableMapAt(offsets[index]) + + override fun getArray(index: Int): ReadableArray? = context.nullableArrayAt(offsets[index]) + + override fun getDynamic(index: Int): Dynamic = DynamicFromArray.create(this, index) + + override fun toArrayList(): ArrayList = + ArrayList(offsets.size).apply { + for (offset in offsets) add(context.materializedValueAt(offset)) + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/TokenStreamMapTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/TokenStreamMapTest.kt new file mode 100644 index 000000000000..9c3e22031a54 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/TokenStreamMapTest.kt @@ -0,0 +1,545 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.bridge + +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import java.io.ByteArrayOutputStream +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Test + +@OptIn(UnstableReactNativeAPI::class) +class TokenStreamMapTest { + private class Writer { + private val out = ByteArrayOutputStream().apply { write(byteArrayOf(0x42, 0x54, 0x53, 1)) } + private val strings = HashMap() + + fun tag(tag: Int) = apply { out.write(tag) } + + fun position(): Int = out.size() + + fun bytes(): ByteArray = out.toByteArray() + + fun varint(value: Long) = apply { + var remaining = value + while (remaining ushr 7 != 0L) { + out.write(((remaining and 0x7f) or 0x80).toInt()) + remaining = remaining ushr 7 + } + out.write(remaining.toInt()) + } + + private fun intern(tag: Int, value: String) = apply { + val id = strings[value] + if (id != null) { + tag(tag + 1).varint(id.toLong()) + } else { + strings[value] = strings.size + val bytes = value.toByteArray(Charsets.UTF_8) + tag(tag).varint(bytes.size.toLong()) + out.write(bytes) + } + } + + fun name(name: String) = intern(12, name) + + fun string(value: String) = intern(10, value) + + fun integer(value: Long) = tag(8).varint((value shl 1) xor (value shr 63)) + + fun number(text: String) = apply { + val bytes = text.toByteArray(Charsets.US_ASCII) + tag(9).varint(bytes.size.toLong()) + out.write(bytes) + } + } + + @Test + fun readsNativeWriterSharedStringFixtureInReverseOrder() { + val bytes = + byteArrayOf( + 0x42, + 0x54, + 0x53, + 1, + 3, + 1, + 12, + 6, + 0x73, + 0x68, + 0x61, + 0x72, + 0x65, + 0x64, + 11, + 0, + 2, + 11, + 0, + 1, + 12, + 5, + 0x6f, + 0x74, + 0x68, + 0x65, + 0x72, + 3, + 11, + 0, + 1, + 13, + 0, + 11, + 1, + 2, + 4, + 2, + 4, + ) + val context = TokenStreamContext.fromOwnedBytes(bytes) + val later = context.getMap(19) + val nested = checkNotNull(later.getArray("other")) + val first = context.getMap(5) + + assertThat(nested.getString(0)).isEqualTo("shared") + assertThat(nested.getString(0)).isSameAs(first.getString("shared")) + assertThat(nested.getMap(1)?.getString("shared")).isSameAs(later.keySetIterator().nextKey()) + assertThat(context.getArray().getString(1)).isSameAs(first.getString("shared")) + assertThat(context.getArray().size()).isEqualTo(3) + } + + @Test + fun mapsReadInReverseOrderShareStrings() { + val writer = Writer().tag(3) + val firstOffset = writer.position() + writer.tag(1).name("title").string("shared").name("n").integer(1).tag(2) + val secondOffset = writer.position() + writer.tag(1).name("title").string("shared").name("n").integer(2).tag(2).tag(4) + val context = TokenStreamContext(writer.bytes()) + val second = context.getMap(secondOffset) + val first = context.getMap(firstOffset) + + assertThat(second.getString("title")).isEqualTo("shared") + assertThat(second.getInt("n")).isEqualTo(2) + assertThat(first.getInt("n")).isEqualTo(1) + assertThat(first.getString("title")).isSameAs(second.getString("title")) + assertThat(context.getArray().getMap(1)?.getString("title")).isSameAs(first.getString("title")) + assertThat(first.getString("title")).isSameAs(first.getString("title")) + } + + @Test + fun referencesInsideSkippedMapsAndArraysAreIndexed() { + val writer = + Writer() + .tag(3) + .tag(1) + .name("nested") + .tag(3) + .tag(1) + .name("shared") + .string("value") + .tag(2) + .tag(4) + .tag(2) + val laterOffset = writer.position() + writer.tag(1).name("shared").string("value").tag(2).tag(4) + val context = TokenStreamContext(writer.bytes()) + val later = context.getMap(laterOffset) + + assertThat(later.getString("shared")).isEqualTo("value") + val earlier = checkNotNull(context.getArray().getMap(0)) + val nested = checkNotNull(earlier.getArray("nested")?.getMap(0)) + assertThat(nested.getString("shared")).isSameAs(later.getString("shared")) + } + + @Test + fun namesAndValuesShareOneStringTable() { + val writer = Writer().tag(1).name("same").string("same").tag(2) + val map = TokenStreamContext(writer.bytes()).getMap() + val key = map.keySetIterator().nextKey() + + assertThat(map.getString("same")).isSameAs(key) + } + + @Test + fun iteratorsAndNestedReadsHaveIndependentState() { + val writer = + Writer() + .tag(1) + .name("a") + .tag(1) + .name("x") + .integer(3) + .tag(2) + .name("b") + .tag(3) + .integer(4) + .integer(5) + .tag(4) + .tag(2) + val map = TokenStreamContext(writer.bytes()).getMap() + val first = map.keySetIterator() + val second = map.keySetIterator() + + assertThat(first.nextKey()).isEqualTo("a") + assertThat(map.getArray("b")?.getInt(1)).isEqualTo(5) + assertThat(map.getMap("a")?.getInt("x")).isEqualTo(3) + assertThat(second.nextKey()).isEqualTo("a") + assertThat(first.nextKey()).isEqualTo("b") + assertThat(second.nextKey()).isEqualTo("b") + assertThat(first.hasNextKey()).isFalse() + assertThat(map.entryIterator.asSequence().map { it.key }.toList()).containsExactly("a", "b") + } + + @Test + fun retainedMapOwnsItsBytes() { + val writer = Writer().tag(3).tag(1).name("value").string("retained").tag(2).tag(4) + val bytes = writer.bytes() + val map = checkNotNull(TokenStreamContext(bytes).getArray().getMap(0)) + bytes.fill(0) + + assertThat(map.getString("value")).isEqualTo("retained") + assertThat(map.toHashMap()).containsEntry("value", "retained") + } + + @Test + fun ownedBufferAvoidsCopyingWhileDefaultConstructorStillCopies() { + val writer = Writer().tag(1).name("value") + val valueOffset = writer.position() + 2 + writer.string("retained").tag(2) + val bytes = writer.bytes() + val owned = TokenStreamContext.fromOwnedBytes(bytes).getMap() + val copied = TokenStreamContext(bytes).getMap() + + // Mutating transferred input is a copy-detection probe, not a permitted caller operation. + bytes[valueOffset] = 'c'.code.toByte() + assertThat(owned.getString("value")).isEqualTo("cetained") + assertThat(copied.getString("value")).isEqualTo("retained") + } + + @Test + fun reopenedMapsReuseTheSharedFieldIndex() { + val writer = Writer().tag(1).name("value").integer(42).tag(2) + val context = TokenStreamContext(writer.bytes()) + val first = context.getMap() + assertThat(first.getInt("value")).isEqualTo(42) + val fields = context.containerAt(4).fields + val second = context.getMap() + assertThat(second.getInt("value")).isEqualTo(42) + assertThat(context.containerAt(4).fields).isSameAs(fields) + } + + @Test + fun integerConversionMatchesExistingReadableMaps() { + val values = + listOf( + Long.MIN_VALUE, + Int.MIN_VALUE.toLong() - 1, + Int.MIN_VALUE.toLong(), + -1L, + 0L, + 1L, + Int.MAX_VALUE.toLong(), + Int.MAX_VALUE.toLong() + 1, + 9007199254740993L, + Long.MAX_VALUE, + ) + val writer = Writer().tag(1) + values.forEachIndexed { index, value -> writer.name(index.toString()).integer(value) } + writer.name("array").tag(3) + values.forEach { writer.integer(it) } + writer.tag(4).tag(2) + val map = TokenStreamContext(writer.bytes()).getMap() + val array = checkNotNull(map.getArray("array")) + values.forEachIndexed { index, value -> + val expected = JavaOnlyMap.of("value", value).getInt("value") + assertThat(map.getInt(index.toString())).isEqualTo(expected) + assertThat(array.getInt(index)).isEqualTo(expected) + assertThat(map.getLong(index.toString())).isEqualTo(value) + } + } + + @Test + fun materializedCollectionsDoNotAliasEachOtherOrTheContext() { + val writer = + Writer() + .tag(1) + .name("nested") + .tag(1) + .name("n") + .integer(7) + .tag(2) + .name("array") + .tag(3) + .integer(9) + .tag(4) + .tag(2) + val map = TokenStreamContext(writer.bytes()).getMap() + val first = map.toHashMap() + val second = map.toHashMap() + assertThat(first).isNotSameAs(second) + assertThat(first["nested"]).isNotSameAs(second["nested"]) + assertThat(first["array"]).isNotSameAs(second["array"]) + first.clear() + val array = checkNotNull(map.getArray("array")) + val materialized = array.toArrayList() + materialized.clear() + assertThat(second).isEqualTo(mapOf("nested" to mapOf("n" to 7.0), "array" to listOf(9.0))) + assertThat(map.getMap("nested")?.getInt("n")).isEqualTo(7) + assertThat(array.getInt(0)).isEqualTo(9) + } + + @Test + fun preservesNullMissingAndTypeErrors() { + val writer = Writer().tag(1).name("reset").tag(5).name("text").string("value").tag(2) + val map = TokenStreamContext(writer.bytes()).getMap() + + assertThat(map.hasKey("reset")).isTrue() + assertThat(map.isNull("reset")).isTrue() + assertThat(map.getType("reset")).isEqualTo(ReadableType.Null) + assertThat(map.getString("reset")).isNull() + assertThat(map.getMap("reset")).isNull() + assertThat(map.getArray("reset")).isNull() + assertThat(map.hasKey("missing")).isFalse() + assertThatThrownBy { map.isNull("missing") }.isInstanceOf(NoSuchKeyException::class.java) + assertThatThrownBy { map.getString("missing") }.isInstanceOf(NoSuchKeyException::class.java) + assertThatThrownBy { map.getDouble("text") } + .isInstanceOf(UnexpectedNativeTypeException::class.java) + assertThatThrownBy { map.getBoolean("reset") } + .isInstanceOf(UnexpectedNativeTypeException::class.java) + } + + @Test + fun readsAllScalarTypesAndNumericBoundaries() { + val writer = + Writer() + .tag(1) + .name("true") + .tag(7) + .name("false") + .tag(6) + .name("int") + .integer(-129) + .name("max") + .integer(Long.MAX_VALUE) + .name("min") + .integer(Long.MIN_VALUE) + .name("fraction") + .number("1.5") + .name("negativeZero") + .number("-0") + .name("nan") + .number("NaN") + .name("infinity") + .number("Infinity") + .name("text") + .string("日本語 🦊\u0000�") + .tag(2) + val map = TokenStreamContext(writer.bytes()).getMap() + + assertThat(map.getBoolean("true")).isTrue() + assertThat(map.getBoolean("false")).isFalse() + assertThat(map.getInt("int")).isEqualTo(-129) + assertThat(map.getLong("max")).isEqualTo(Long.MAX_VALUE) + assertThat(map.getLong("min")).isEqualTo(Long.MIN_VALUE) + assertThat(map.getDouble("fraction")).isEqualTo(1.5) + assertThat(map.getInt("fraction")).isEqualTo(1) + assertThat(map.getDouble("negativeZero").toRawBits()).isEqualTo((-0.0).toRawBits()) + assertThat(map.getDouble("nan")).isNaN() + assertThat(map.getDouble("infinity")).isEqualTo(Double.POSITIVE_INFINITY) + assertThat(map.getString("text")).isEqualTo("日本語 🦊\u0000�") + assertThat(map.getDynamic("fraction").asDouble()).isEqualTo(1.5) + } + + @Test + fun materializesNestedMapsAndArrays() { + val writer = + Writer() + .tag(1) + .name("nested") + .tag(1) + .name("n") + .integer(7) + .tag(2) + .name("array") + .tag(3) + .tag(5) + .tag(7) + .number("0.5") + .string("x") + .tag(1) + .name("n") + .integer(8) + .tag(2) + .tag(3) + .integer(9) + .tag(4) + .tag(4) + .tag(2) + val map = TokenStreamContext(writer.bytes()).getMap() + val array = checkNotNull(map.getArray("array")) + + assertThat(map.getType("nested")).isEqualTo(ReadableType.Map) + assertThat(map.getType("array")).isEqualTo(ReadableType.Array) + assertThat(array.size()).isEqualTo(6) + assertThat(array.isNull(0)).isTrue() + assertThat(array.getBoolean(1)).isTrue() + assertThat(array.getDouble(2)).isEqualTo(0.5) + assertThat(array.getString(3)).isEqualTo("x") + assertThat(array.getMap(4)?.getInt("n")).isEqualTo(8) + assertThat(array.getArray(5)?.getInt(0)).isEqualTo(9) + assertThat(array.getDynamic(3).asString()).isEqualTo("x") + assertThat(map.toHashMap()) + .isEqualTo( + mapOf( + "nested" to mapOf("n" to 7.0), + "array" to listOf(null, true, 0.5, "x", mapOf("n" to 8.0), listOf(9.0)), + ), + ) + assertThat(array.toArrayList()) + .isEqualTo(listOf(null, true, 0.5, "x", mapOf("n" to 8.0), listOf(9.0))) + assertThatThrownBy { array.getString(6) }.isInstanceOf(IndexOutOfBoundsException::class.java) + } + + @Test + fun duplicateNamesKeepTheLastValue() { + val writer = Writer().tag(1).name("a").integer(1).name("a").integer(2).tag(2) + val map = TokenStreamContext(writer.bytes()).getMap() + + assertThat(map.getInt("a")).isEqualTo(2) + assertThat(map.toHashMap()).hasSize(1) + } + + @Test + fun concurrentReadersShareTheDecodedStringInstance() { + val writer = Writer().tag(3) + val offsets = ArrayList() + repeat(32) { + offsets.add(writer.position()) + writer.tag(1).name("value").string("shared".repeat(128)).tag(2) + } + writer.tag(4) + val context = TokenStreamContext(writer.bytes()) + val results = arrayOfNulls(offsets.size) + val failures = ConcurrentLinkedQueue() + val completed = CountDownLatch(offsets.size) + offsets.reversed().forEachIndexed { index, offset -> + Thread { + try { + results[index] = context.getMap(offset).getString("value") + } catch (error: Throwable) { + failures.add(error) + } finally { + completed.countDown() + } + } + .apply { isDaemon = true } + .start() + } + assertThat(completed.await(10, TimeUnit.SECONDS)).isTrue() + assertThat(failures).isEmpty() + for (value in results) assertThat(value).isSameAs(results.first()) + } + + @Test + fun rejectsInvalidHeadersAndOffsets() { + assertThatThrownBy { TokenStreamContext(byteArrayOf()) } + .isInstanceOf(IllegalArgumentException::class.java) + assertThatThrownBy { TokenStreamContext(byteArrayOf(0x42, 0x54, 0x53, 2, 1, 2)) } + .isInstanceOf(IllegalArgumentException::class.java) + val context = TokenStreamContext(Writer().tag(1).name("a").integer(1).tag(2).bytes()) + assertThatThrownBy { context.getMap(5) }.isInstanceOf(IllegalArgumentException::class.java) + assertThatThrownBy { context.getArray() }.isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun rejectsMalformedTokensAndReferences() { + val malformed = + listOf( + Writer().bytes(), + Writer().tag(1).bytes(), + Writer().tag(2).bytes(), + Writer().tag(1).tag(4).bytes(), + Writer().tag(1).name("a").tag(2).bytes(), + Writer().tag(1).integer(1).tag(2).bytes(), + Writer().name("a").bytes(), + Writer().tag(3).name("a").tag(4).bytes(), + Writer().tag(1).name("a").name("b").integer(1).tag(2).bytes(), + Writer().tag(5).tag(5).bytes(), + Writer().tag(11).varint(0).bytes(), + Writer().tag(10).varint(-1L).bytes(), + Writer().tag(10).varint(100).bytes(), + Writer().number("not a number").bytes(), + Writer().tag(14).bytes(), + Writer().tag(255).bytes(), + ) + for ((index, bytes) in malformed.withIndex()) { + assertThatThrownBy { TokenStreamContext(bytes) } + .describedAs("case %s", index) + .isInstanceOf(IllegalArgumentException::class.java) + } + } + + @Test + fun rejectsInvalidUtf8AndOverflowingVarints() { + val invalid = + listOf( + byteArrayOf(0xc0.toByte(), 0x80.toByte()), + byteArrayOf(0xed.toByte(), 0xa0.toByte(), 0x80.toByte()), + byteArrayOf(0xf4.toByte(), 0x90.toByte(), 0x80.toByte(), 0x80.toByte()), + byteArrayOf(0xe2.toByte(), 0x82.toByte()), + byteArrayOf(0xe2.toByte(), 0x28, 0xa1.toByte()), + ) + for (bytes in invalid) { + val prefix = Writer().tag(10).varint(bytes.size.toLong()).bytes() + assertThatThrownBy { TokenStreamContext(prefix + bytes) } + .isInstanceOf(IllegalArgumentException::class.java) + } + val overflow = Writer().tag(8).bytes() + ByteArray(9) { 0x80.toByte() } + byteArrayOf(2) + assertThatThrownBy { TokenStreamContext(overflow) } + .isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun indexesDeepDocumentsWithoutRecursiveParsing() { + val writer = Writer() + repeat(2048) { writer.tag(3) } + writer.tag(1).name("value").integer(42).tag(2) + repeat(2048) { writer.tag(4) } + val context = TokenStreamContext(writer.bytes()) + var array = context.getArray() + repeat(2047) { array = checkNotNull(array.getArray(0)) } + + assertThat(array.getMap(0)?.getInt("value")).isEqualTo(42) + var materialized: Any? = context.getArray().toArrayList() + repeat(2048) { materialized = (checkNotNull(materialized) as List<*>)[0] } + assertThat(materialized).isEqualTo(mapOf("value" to 42.0)) + } + + @Test + fun materializesDeepMapsAndArraysWithoutRecursiveCalls() { + val writer = Writer() + repeat(2048) { index -> if (index % 2 == 0) writer.tag(1).name("child") else writer.tag(3) } + writer.tag(1).name("value").integer(42).tag(2) + for (index in 2047 downTo 0) writer.tag(if (index % 2 == 0) 2 else 4) + val context = TokenStreamContext(writer.bytes()) + var materialized: Any? = context.getMap().toHashMap() + repeat(2048) { index -> + materialized = + if (index % 2 == 0) (checkNotNull(materialized) as Map<*, *>)["child"] + else (checkNotNull(materialized) as List<*>)[0] + } + + assertThat(materialized).isEqualTo(mapOf("value" to 42.0)) + } +} diff --git a/packages/react-native/ReactCommon/react/utils/CMakeLists.txt b/packages/react-native/ReactCommon/react/utils/CMakeLists.txt index 5dbfdf013b05..7ec718709e37 100644 --- a/packages/react-native/ReactCommon/react/utils/CMakeLists.txt +++ b/packages/react-native/ReactCommon/react/utils/CMakeLists.txt @@ -29,6 +29,8 @@ target_include_directories(react_utils target_include_directories(react_utils INTERFACE ${REACT_COMMON_DIR}/react/utils) target_link_libraries(react_utils + fmt + folly_runtime glog glog_init jsi diff --git a/packages/react-native/ReactCommon/react/utils/React/Utils.h b/packages/react-native/ReactCommon/react/utils/React/Utils.h index 551988537982..5f638347ce9d 100644 --- a/packages/react-native/ReactCommon/react/utils/React/Utils.h +++ b/packages/react-native/ReactCommon/react/utils/React/Utils.h @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include diff --git a/packages/react-native/ReactCommon/react/utils/TokenStreamWriter.cpp b/packages/react-native/ReactCommon/react/utils/TokenStreamWriter.cpp new file mode 100644 index 000000000000..f3959b8ff1a0 --- /dev/null +++ b/packages/react-native/ReactCommon/react/utils/TokenStreamWriter.cpp @@ -0,0 +1,292 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "TokenStreamWriter.h" + +#include +#include + +#include +#include +#include + +namespace facebook::react { +namespace { + +constexpr uint8_t kStartObject = 0x01; +constexpr uint8_t kEndObject = 0x02; +constexpr uint8_t kStartArray = 0x03; +constexpr uint8_t kEndArray = 0x04; +constexpr uint8_t kNull = 0x05; +constexpr uint8_t kFalse = 0x06; +constexpr uint8_t kTrue = 0x07; +constexpr uint8_t kInteger = 0x08; +constexpr uint8_t kNumber = 0x09; +constexpr uint8_t kString = 0x0a; +constexpr uint8_t kName = 0x0c; +constexpr size_t kPendingOffset = std::numeric_limits::max(); + +bool validUnicode(std::string_view value) { + size_t position = 0; + while (position < value.size()) { + const auto lead = static_cast(value[position++]); + if (lead < 0x80) { + continue; + } + size_t count; + if (lead >= 0xc2 && lead <= 0xdf) { + count = 1; + } else if (lead >= 0xe0 && lead <= 0xef) { + count = 2; + } else if (lead >= 0xf0 && lead <= 0xf4) { + count = 3; + } else { + return false; + } + if (count > value.size() - position) { + return false; + } + const auto second = static_cast(value[position]); + if ((lead == 0xe0 && second < 0xa0) || (lead == 0xed && second > 0x9f) || + (lead == 0xf0 && second < 0x90) || (lead == 0xf4 && second > 0x8f)) { + return false; + } + for (size_t i = 0; i < count; ++i) { + if ((static_cast(value[position++]) & 0xc0) != 0x80) { + return false; + } + } + } + return true; +} + +struct ValueFrame { + const folly::dynamic* input; + folly::dynamic::const_item_iterator next; + folly::dynamic::const_item_iterator end; + size_t index = 0; + + explicit ValueFrame(const folly::dynamic& value) : input(&value) { + if (value.isObject()) { + next = value.items().begin(); + end = value.items().end(); + } + } +}; + +} // namespace + +TokenStreamWriter::TokenStreamWriter(size_t maxBytes) + : bytes_{'B', 'T', 'S', 1, kStartArray}, + strings_(0, StringHash{}, StringEqual{*this}), + maxBytes_( + std::min( + maxBytes, + static_cast(std::numeric_limits::max()))) {} + +size_t TokenStreamWriter::StringHash::operator()( + const Entry& entry) const noexcept { + return entry.hash; +} + +bool TokenStreamWriter::StringEqual::operator()( + const Entry& first, + const Entry& second) const noexcept { + return writer.get().view(first) == writer.get().view(second); +} + +std::string_view TokenStreamWriter::view(const Entry& entry) const noexcept { + if (entry.offset == kPendingOffset) { + return pendingString_; + } + return { + reinterpret_cast(bytes_.data() + entry.offset), + entry.length}; +} + +bool TokenStreamWriter::append(const void* data, size_t length) { + // Reserve the enclosing array's closing tag in every size check. + if (maxBytes_ == 0 || length > maxBytes_ - 1 || + bytes_.size() > maxBytes_ - 1 - length) { + return false; + } + if (length != 0) { + const auto* begin = static_cast(data); + bytes_.insert(bytes_.end(), begin, begin + length); + } + return true; +} + +bool TokenStreamWriter::tag(uint8_t value) { + return append(&value, 1); +} + +bool TokenStreamWriter::varint(uint64_t value) { + uint8_t encoded[10]; + size_t length = 0; + while (value >= 0x80) { + encoded[length++] = static_cast((value & 0x7f) | 0x80); + value >>= 7; + } + encoded[length++] = static_cast(value); + return append(encoded, length); +} + +bool TokenStreamWriter::numberText(std::string_view text) { + return tag(kNumber) && varint(text.size()) && + append(text.data(), text.size()); +} + +bool TokenStreamWriter::string( + std::string_view text, + uint8_t literalTag, + Error& error) { + if (!validUnicode(text)) { + error = Error::InvalidUnicode; + return false; + } + pendingString_ = text; + const auto hash = std::hash{}(text); + const auto found = strings_.find(Entry{kPendingOffset, text.size(), hash, 0}); + if (found != strings_.end()) { + pendingString_ = {}; + return tag(literalTag + 1) && varint(found->index); + } + if (!tag(literalTag) || !varint(text.size())) { + pendingString_ = {}; + return false; + } + const auto offset = bytes_.size(); + if (!append(text.data(), text.size())) { + pendingString_ = {}; + return false; + } + // Offsets survive output-vector growth; views into that vector would not. + strings_.insert(Entry{offset, text.size(), hash, nextIndex_++}); + pendingString_ = {}; + return true; +} + +bool TokenStreamWriter::scalar(const folly::dynamic& input, Error& error) { + if (input.isNull()) { + return tag(kNull); + } + if (input.isBool()) { + return tag(input.getBool() ? kTrue : kFalse); + } + if (input.isInt()) { + const auto integer = input.getInt(); + const auto zigzag = (static_cast(integer) << 1) ^ + (integer < 0 ? std::numeric_limits::max() : 0); + return tag(kInteger) && varint(zigzag); + } + if (input.isDouble()) { + const auto number = input.getDouble(); + if (std::isnan(number)) { + return numberText("NaN"); + } + if (std::isinf(number)) { + return numberText(number < 0 ? "-Infinity" : "Infinity"); + } + if (number == 0 && std::signbit(number)) { + return numberText("-0"); + } + fmt::memory_buffer text; + fmt::format_to(std::back_inserter(text), "{}", number); + return numberText({text.data(), text.size()}); + } + return string(input.getString(), kString, error); +} + +bool TokenStreamWriter::value(const folly::dynamic& input, Error& error) { + std::vector stack; + const folly::dynamic* current = &input; + while (current != nullptr || !stack.empty()) { + if (current != nullptr) { + if (current->isObject() || current->isArray()) { + if (!tag(current->isObject() ? kStartObject : kStartArray)) { + return false; + } + stack.emplace_back(*current); + } else if (!scalar(*current, error)) { + return false; + } + current = nullptr; + } + while (!stack.empty()) { + auto& frame = stack.back(); + if (frame.input->isArray()) { + if (frame.index < frame.input->size()) { + current = &(*frame.input)[frame.index++]; + break; + } + } else if (frame.next != frame.end) { + const auto& property = *frame.next++; + if (!property.first.isString()) { + error = Error::NonStringKey; + return false; + } + if (!string(property.first.getString(), kName, error)) { + return false; + } + current = &property.second; + break; + } + if (!tag(frame.input->isObject() ? kEndObject : kEndArray)) { + return false; + } + stack.pop_back(); + } + } + return true; +} + +void TokenStreamWriter::rollback(size_t offset, uint32_t stringIndex) { + // Remove entries while their bytes still exist, before shrinking the output. + for (auto entry = strings_.begin(); entry != strings_.end();) { + if (entry->index >= stringIndex) { + entry = strings_.erase(entry); + } else { + ++entry; + } + } + bytes_.resize(offset); + nextIndex_ = stringIndex; + pendingString_ = {}; +} + +folly::Expected +TokenStreamWriter::write(const folly::dynamic& input) { + if (finished_) { + return folly::makeUnexpected(Error::AlreadyFinished); + } + const auto offset = bytes_.size(); + const auto stringIndex = nextIndex_; + auto error = Error::SizeLimitExceeded; + if (!value(input, error)) { + rollback(offset, stringIndex); + return folly::makeUnexpected(error); + } + return ValueRange{ + static_cast(offset), + static_cast(bytes_.size() - offset)}; +} + +folly::Expected, TokenStreamWriter::Error> +TokenStreamWriter::finish() && { + if (finished_) { + return folly::makeUnexpected(Error::AlreadyFinished); + } + finished_ = true; + if (bytes_.size() >= maxBytes_) { + return folly::makeUnexpected(Error::SizeLimitExceeded); + } + bytes_.push_back(kEndArray); + return folly::makeExpected(std::move(bytes_)); +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/utils/TokenStreamWriter.h b/packages/react-native/ReactCommon/react/utils/TokenStreamWriter.h new file mode 100644 index 000000000000..6d7ab2b98ad3 --- /dev/null +++ b/packages/react-native/ReactCommon/react/utils/TokenStreamWriter.h @@ -0,0 +1,105 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace folly { +struct dynamic; +} + +namespace facebook::react { + +/** + * Appends native values to one version-one binary token stream array document. + * Names and string values share a table across all appended values. Returned + * ranges address values inside that document, not independently decodable streams. + * No JavaScript runtime is needed, and input values are not copied or modified. + */ +class TokenStreamWriter final { + public: + struct ValueRange { + uint32_t offset; + uint32_t length; + + bool operator==(const ValueRange &) const = default; + }; + + enum class Error : uint8_t { + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, + AlreadyFinished, + }; + + /** The byte limit includes the document header and enclosing array. */ + explicit TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + + TokenStreamWriter(const TokenStreamWriter &) = delete; + TokenStreamWriter(TokenStreamWriter &&) = delete; + TokenStreamWriter &operator=(const TokenStreamWriter &) = delete; + TokenStreamWriter &operator=(TokenStreamWriter &&) = delete; + ~TokenStreamWriter() = default; + + /** + * Encodes a value without recursion. Invalid Unicode, non-string object keys + * and oversized output leave the document and string table unchanged. + * Integers retain their full width; doubles retain finite values, signed zero, + * infinities and the distinction between a number and null. + */ + [[nodiscard]] folly::Expected write(const folly::dynamic &value); + + /** Completes the array and transfers ownership of its bytes without copying. */ + [[nodiscard]] folly::Expected, Error> finish() &&; + + private: + struct Entry { + size_t offset; + size_t length; + size_t hash; + uint32_t index; + }; + + struct StringHash { + size_t operator()(const Entry &entry) const noexcept; + }; + + struct StringEqual { + std::reference_wrapper writer; + bool operator()(const Entry &first, const Entry &second) const noexcept; + }; + + std::vector bytes_; + std::unordered_set strings_; + std::string_view pendingString_; + size_t maxBytes_; + uint32_t nextIndex_ = 0; + bool finished_ = false; + + std::string_view view(const Entry &entry) const noexcept; + bool append(const void *data, size_t length); + bool tag(uint8_t value); + bool varint(uint64_t value); + bool numberText(std::string_view text); + bool string(std::string_view value, uint8_t literalTag, Error &error); + bool scalar(const folly::dynamic &value, Error &error); + bool value(const folly::dynamic &value, Error &error); + void rollback(size_t offset, uint32_t stringIndex); +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/utils/tests/TokenStreamWriterTest.cpp b/packages/react-native/ReactCommon/react/utils/tests/TokenStreamWriterTest.cpp new file mode 100644 index 000000000000..a6a7d5dc244f --- /dev/null +++ b/packages/react-native/ReactCommon/react/utils/tests/TokenStreamWriterTest.cpp @@ -0,0 +1,753 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace facebook::react { +namespace { + +using Error = TokenStreamWriter::Error; +using ValueRange = TokenStreamWriter::ValueRange; + +// This decoder is only used for shallow documents, never the deep input test. +class TestTokenStreamDecoder { + public: + explicit TestTokenStreamDecoder(const std::vector& bytes) + : bytes_(bytes) {} + + folly::dynamic readDocument() { + for (const auto expected : {0x42, 0x54, 0x53, 0x01, 0x03}) { + if (byte() != expected) { + throw std::runtime_error("Invalid document header"); + } + } + auto result = folly::dynamic::array(); + while (bytes_.at(position_) != 0x04) { + result.push_back(value()); + } + ++position_; + if (position_ != bytes_.size()) { + throw std::runtime_error("Unexpected trailing bytes"); + } + return result; + } + + private: + uint8_t byte() { + return bytes_.at(position_++); + } + + uint64_t unsignedInteger() { + uint64_t result = 0; + for (unsigned int shift = 0; shift < 64; shift += 7) { + const auto next = byte(); + if (shift == 63 && (next & 0xfe) != 0) { + throw std::runtime_error("Invalid unsigned integer"); + } + result |= static_cast(next & 0x7f) << shift; + if ((next & 0x80) == 0) { + return result; + } + } + throw std::runtime_error("Unterminated unsigned integer"); + } + + std::string text() { + const auto length = unsignedInteger(); + if (length > bytes_.size() - position_) { + throw std::runtime_error("Truncated text"); + } + std::string result( + reinterpret_cast(bytes_.data() + position_), length); + position_ += length; + return result; + } + + std::string string(uint8_t tag) { + if (tag == 0x0b || tag == 0x0d) { + return strings_.at(unsignedInteger()); + } + if (tag != 0x0a && tag != 0x0c) { + throw std::runtime_error("Unexpected string tag"); + } + auto result = text(); + strings_.push_back(result); + return result; + } + + folly::dynamic value() { + const auto tag = byte(); + switch (tag) { + case 0x01: { + folly::dynamic result = folly::dynamic::object(); + while (bytes_.at(position_) != 0x02) { + const auto nameTag = byte(); + if (nameTag != 0x0c && nameTag != 0x0d) { + throw std::runtime_error("Unexpected object name tag"); + } + auto name = string(nameTag); + auto child = value(); + result[std::move(name)] = std::move(child); + } + ++position_; + return result; + } + case 0x03: { + auto result = folly::dynamic::array(); + while (bytes_.at(position_) != 0x04) { + result.push_back(value()); + } + ++position_; + return result; + } + case 0x05: + return nullptr; + case 0x06: + return false; + case 0x07: + return true; + case 0x08: { + const auto encoded = unsignedInteger(); + const auto magnitude = static_cast(encoded >> 1); + return (encoded & 1) != 0 ? -magnitude - 1 : magnitude; + } + case 0x09: { + const auto number = text(); + char* end = nullptr; + const auto result = std::strtod(number.c_str(), &end); + if (number.empty() || end != number.c_str() + number.size()) { + throw std::runtime_error("Invalid number text"); + } + return result; + } + case 0x0a: + case 0x0b: + return string(tag); + default: + throw std::runtime_error("Unexpected value tag"); + } + } + + const std::vector& bytes_; + size_t position_ = 0; + std::vector strings_; +}; + +uint64_t doubleBits(double value) { + uint64_t result; + static_assert(sizeof(result) == sizeof(value)); + std::memcpy(&result, &value, sizeof(result)); + return result; +} + +std::vector valueBytes( + const std::vector& document, + ValueRange range) { + if (range.offset > document.size() || + range.length > document.size() - range.offset) { + throw std::runtime_error("Value range is outside the document"); + } + return { + document.begin() + range.offset, + document.begin() + range.offset + range.length}; +} + +} // namespace + +TEST(TokenStreamWriterTest, finishesEmptyDocument) { + TokenStreamWriter writer; + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ( + *document, (std::vector{0x42, 0x54, 0x53, 0x01, 0x03, 0x04})); +} + +TEST(TokenStreamWriterTest, writesGoldenSharedStringDocumentAndRanges) { + TokenStreamWriter writer; + auto first = writer.write(folly::dynamic::object("shared", "shared")); + auto second = writer.write("shared"); + auto third = writer.write( + folly::dynamic::object( + "other", + folly::dynamic::array( + "shared", folly::dynamic::object("shared", "other")))); + ASSERT_TRUE(first.hasValue()); + ASSERT_TRUE(second.hasValue()); + ASSERT_TRUE(third.hasValue()); + EXPECT_EQ(*first, (ValueRange{5, 12})); + EXPECT_EQ(*second, (ValueRange{17, 2})); + EXPECT_EQ(*third, (ValueRange{19, 19})); + + // Derived from the format: "shared" has index zero, "other" has index one. + const std::vector expected{ + 0x42, 0x54, 0x53, 0x01, 0x03, 0x01, 0x0c, 0x06, 's', 'h', + 'a', 'r', 'e', 'd', 0x0b, 0x00, 0x02, 0x0b, 0x00, 0x01, + 0x0c, 0x05, 'o', 't', 'h', 'e', 'r', 0x03, 0x0b, 0x00, + 0x01, 0x0d, 0x00, 0x0b, 0x01, 0x02, 0x04, 0x02, 0x04}; + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ(*document, expected); +} + +TEST(TokenStreamWriterTest, sharesValueStringsWithNamesAcrossMaps) { + TokenStreamWriter writer; + ASSERT_TRUE(writer.write("key").hasValue()); + ASSERT_TRUE(writer.write(folly::dynamic::object("key", "value")).hasValue()); + ASSERT_TRUE(writer.write(folly::dynamic::object("value", "key")).hasValue()); + + const std::vector expected{0x42, 0x54, 0x53, 0x01, 0x03, 0x0a, 0x03, + 'k', 'e', 'y', 0x01, 0x0d, 0x00, 0x0a, + 0x05, 'v', 'a', 'l', 'u', 'e', 0x02, + 0x01, 0x0d, 0x01, 0x0b, 0x00, 0x02, 0x04}; + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ(*document, expected); +} + +TEST(TokenStreamWriterTest, sharesEmptyStringsBetweenNamesAndValues) { + TokenStreamWriter writer; + ASSERT_TRUE(writer.write("").hasValue()); + ASSERT_TRUE(writer.write(folly::dynamic::object("", "")).hasValue()); + ASSERT_TRUE(writer.write("").hasValue()); + + const std::vector expected{ + 0x42, + 0x54, + 0x53, + 0x01, + 0x03, + 0x0a, + 0x00, + 0x01, + 0x0d, + 0x00, + 0x0b, + 0x00, + 0x02, + 0x0b, + 0x00, + 0x04}; + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ(*document, expected); +} + +TEST(TokenStreamWriterTest, writesScalarTagsAndAbsoluteRanges) { + const std::vector values{ + nullptr, false, true, int64_t{0}, int64_t{-1}, int64_t{64}}; + const std::vector expectedRanges{ + {5, 1}, {6, 1}, {7, 1}, {8, 2}, {10, 2}, {12, 3}}; + TokenStreamWriter writer; + std::vector ranges; + for (const auto& value : values) { + auto range = writer.write(value); + ASSERT_TRUE(range.hasValue()); + ranges.push_back(*range); + } + EXPECT_EQ(ranges, expectedRanges); + + const std::vector expected{ + 0x42, + 0x54, + 0x53, + 0x01, + 0x03, + 0x05, + 0x06, + 0x07, + 0x08, + 0x00, + 0x08, + 0x01, + 0x08, + 0x80, + 0x01, + 0x04}; + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ(*document, expected); +} + +TEST(TokenStreamWriterTest, preservesFullWidthIntegersAndZigzagBoundaries) { + const std::vector>> cases{ + {0, {0x08, 0x00}}, + {-1, {0x08, 0x01}}, + {1, {0x08, 0x02}}, + {-64, {0x08, 0x7f}}, + {63, {0x08, 0x7e}}, + {64, {0x08, 0x80, 0x01}}, + {-65, {0x08, 0x81, 0x01}}, + {std::numeric_limits::min(), + {0x08, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01}}, + {std::numeric_limits::max(), + {0x08, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01}}, + {9007199254740993LL, + {0x08, 0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x20}}, + {-9007199254740993LL, + {0x08, 0x81, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x20}}}; + for (const auto& [value, encoded] : cases) { + SCOPED_TRACE(value); + TokenStreamWriter writer; + auto range = writer.write(value); + ASSERT_TRUE(range.hasValue()); + EXPECT_EQ(*range, (ValueRange{5, static_cast(encoded.size())})); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ(valueBytes(*document, *range), encoded); + const auto decoded = TestTokenStreamDecoder(*document).readDocument(); + ASSERT_TRUE(decoded[0].isInt()); + EXPECT_EQ(decoded[0].getInt(), value); + } +} + +TEST(TokenStreamWriterTest, roundTripsFiniteDoubleBitsIncludingSignedZero) { + const std::vector values{ + 0.0, + -0.0, + 1.0, + -1.0, + 0.1, + 1.0 / 3.0, + -1.0 / 3.0, + std::nextafter(1.0, 0.0), + std::nextafter(1.0, 2.0), + std::numeric_limits::denorm_min(), + -std::numeric_limits::denorm_min(), + std::numeric_limits::min(), + -std::numeric_limits::min(), + std::numeric_limits::max(), + -std::numeric_limits::max(), + 9007199254740992.0, + 9007199254740994.0, + 9223372036854775808.0, + 1e20, + 1e-100, + 1e100}; + TokenStreamWriter writer; + for (const auto value : values) { + ASSERT_TRUE(writer.write(value).hasValue()); + } + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + const auto decoded = TestTokenStreamDecoder(*document).readDocument(); + ASSERT_EQ(decoded.size(), values.size()); + for (size_t index = 0; index < values.size(); ++index) { + SCOPED_TRACE(index); + ASSERT_TRUE(decoded[index].isDouble()); + EXPECT_EQ( + doubleBits(decoded[index].getDouble()), doubleBits(values[index])); + } +} + +TEST(TokenStreamWriterTest, preservesNonfiniteNumbersAsNumberText) { + TokenStreamWriter writer; + ASSERT_TRUE( + writer.write(std::numeric_limits::quiet_NaN()).hasValue()); + ASSERT_TRUE(writer.write(std::numeric_limits::infinity()).hasValue()); + ASSERT_TRUE( + writer.write(-std::numeric_limits::infinity()).hasValue()); + + const std::vector expected{ + 0x42, 0x54, 0x53, 0x01, 0x03, 0x09, 0x03, 'N', 'a', 'N', 0x09, + 0x08, 'I', 'n', 'f', 'i', 'n', 'i', 't', 'y', 0x09, 0x09, + '-', 'I', 'n', 'f', 'i', 'n', 'i', 't', 'y', 0x04}; + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ(*document, expected); + const auto decoded = TestTokenStreamDecoder(*document).readDocument(); + ASSERT_EQ(decoded.size(), 3); + ASSERT_TRUE(decoded[0].isDouble()); + EXPECT_TRUE(std::isnan(decoded[0].getDouble())); + ASSERT_TRUE(decoded[1].isDouble()); + EXPECT_EQ(decoded[1].getDouble(), std::numeric_limits::infinity()); + ASSERT_TRUE(decoded[2].isDouble()); + EXPECT_EQ(decoded[2].getDouble(), -std::numeric_limits::infinity()); +} + +TEST(TokenStreamWriterTest, writesNestedEmptyAndMixedContainers) { + const auto value = folly::dynamic::array( + folly::dynamic::array(), + folly::dynamic::object(), + folly::dynamic::array(nullptr, false, true, int64_t{-42}), + folly::dynamic::object( + "nested", + folly::dynamic::array(folly::dynamic::object("text", "value"), 2.5))); + TokenStreamWriter writer; + auto range = writer.write(value); + ASSERT_TRUE(range.hasValue()); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ( + *range, (ValueRange{5, static_cast(document->size() - 6)})); + EXPECT_EQ( + TestTokenStreamDecoder(*document).readDocument(), + folly::dynamic::array(value)); +} + +TEST(TokenStreamWriterTest, preservesUnicodeControlCharactersAndEmptyStrings) { + const std::vector strings{ + "", + "plain", + std::string("\0\b\f\n\r\t\"\\\x01\x1f", 10), + "caf\xc3\xa9 \xe4\xb8\xad\xe6\x96\x87 \xf0\x9f\x98\x80", + "\x7f\xc2\x80\xdf\xbf\xe0\xa0\x80\xed\x9f\xbf\xee\x80\x80" + "\xef\xbf\xbf\xf0\x90\x80\x80\xf4\x8f\xbf\xbf"}; + auto expected = folly::dynamic::array(); + TokenStreamWriter writer; + for (const auto& string : strings) { + ASSERT_TRUE(writer.write(string).hasValue()); + expected.push_back(string); + const folly::dynamic object = folly::dynamic::object(string, string); + ASSERT_TRUE(writer.write(object).hasValue()); + expected.push_back(object); + } + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ(TestTokenStreamDecoder(*document).readDocument(), expected); +} + +TEST(TokenStreamWriterTest, usesVarintByteLengthsForLongStrings) { + const std::string string(130, 'x'); + TokenStreamWriter writer; + auto range = writer.write(string); + ASSERT_TRUE(range.hasValue()); + EXPECT_EQ(*range, (ValueRange{5, 133})); + auto reference = writer.write(string); + ASSERT_TRUE(reference.hasValue()); + EXPECT_EQ(*reference, (ValueRange{138, 2})); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + + std::vector expected{0x0a, 0x82, 0x01}; + expected.insert(expected.end(), string.begin(), string.end()); + EXPECT_EQ(valueBytes(*document, *range), expected); + EXPECT_EQ( + valueBytes(*document, *reference), (std::vector{0x0b, 0x00})); +} + +TEST( + TokenStreamWriterTest, + keepsReferencesAfterReallocationAndInputDestruction) { + const std::string seed(1024, 's'); + auto expected = folly::dynamic::array(); + TokenStreamWriter writer; + { + auto input = folly::dynamic(seed); + ASSERT_TRUE(writer.write(input).hasValue()); + expected.push_back(input); + input = nullptr; + } + + std::string lastString; + for (size_t index = 0; index < 160; ++index) { + lastString = std::to_string(index) + std::string(2048, 'x'); + auto input = folly::dynamic(lastString); + ASSERT_TRUE(writer.write(input).hasValue()); + expected.push_back(input); + input = "overwritten"; + } + auto seedReference = writer.write(seed); + auto lastReference = writer.write(lastString); + const folly::dynamic object = folly::dynamic::object(lastString, seed); + auto nameReference = writer.write(object); + ASSERT_TRUE(seedReference.hasValue()); + ASSERT_TRUE(lastReference.hasValue()); + ASSERT_TRUE(nameReference.hasValue()); + expected.push_back(seed); + expected.push_back(lastString); + expected.push_back(object); + + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_GT(document->size(), 300000); + EXPECT_EQ( + valueBytes(*document, *seedReference), + (std::vector{0x0b, 0x00})); + EXPECT_EQ( + valueBytes(*document, *lastReference), + (std::vector{0x0b, 0xa0, 0x01})); + EXPECT_EQ( + valueBytes(*document, *nameReference), + (std::vector{0x01, 0x0d, 0xa0, 0x01, 0x0b, 0x00, 0x02})); + EXPECT_EQ(TestTokenStreamDecoder(*document).readDocument(), expected); +} + +TEST(TokenStreamWriterTest, doesNotModifyRepeatedInput) { + const folly::dynamic input = folly::dynamic::object( + "shared", + folly::dynamic::array( + "shared", int64_t{123}, folly::dynamic::object("child", "shared"))); + const auto original = input; + TokenStreamWriter writer; + auto first = writer.write(input); + ASSERT_TRUE(first.hasValue()); + EXPECT_EQ(input, original); + auto second = writer.write(input); + ASSERT_TRUE(second.hasValue()); + EXPECT_EQ(input, original); + EXPECT_LT(second->length, first->length); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ( + TestTokenStreamDecoder(*document).readDocument(), + folly::dynamic::array(original, original)); + EXPECT_EQ(input, original); +} + +TEST(TokenStreamWriterTest, rejectsMalformedUnicodeInNamesAndValues) { + const std::vector invalidStrings{ + "\x80", + "\xc0\xaf", + "\xe0\x80\x80", + "\xf0\x80\x80\x80", + "\xc2", + "\xe2\x82", + "\xf0\x9f\x92", + "\xed\xa0\x80", + "\xed\xbf\xbf", + "\xf4\x90\x80\x80", + "\xf5\x80\x80\x80", + "\xe2\x28\xa1", + "\xff", + std::string("valid\0\x80", 7)}; + for (size_t index = 0; index < invalidStrings.size(); ++index) { + SCOPED_TRACE(index); + TokenStreamWriter writer; + auto value = writer.write(invalidStrings[index]); + ASSERT_TRUE(value.hasError()); + EXPECT_EQ(value.error(), Error::InvalidUnicode); + auto name = + writer.write(folly::dynamic::object(invalidStrings[index], true)); + ASSERT_TRUE(name.hasError()); + EXPECT_EQ(name.error(), Error::InvalidUnicode); + auto valid = writer.write(true); + ASSERT_TRUE(valid.hasValue()); + EXPECT_EQ(*valid, (ValueRange{5, 1})); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ( + *document, + (std::vector{0x42, 0x54, 0x53, 0x01, 0x03, 0x07, 0x04})); + } +} + +TEST(TokenStreamWriterTest, rejectsNonStringObjectKeys) { + const std::vector keys{nullptr, false, int64_t{-7}, 1.5}; + for (const auto& key : keys) { + TokenStreamWriter writer; + auto result = writer.write(folly::dynamic::object(key, "value")); + ASSERT_TRUE(result.hasError()); + EXPECT_EQ(result.error(), Error::NonStringKey); + ASSERT_TRUE(writer.write(nullptr).hasValue()); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ( + *document, + (std::vector{0x42, 0x54, 0x53, 0x01, 0x03, 0x05, 0x04})); + } +} + +TEST( + TokenStreamWriterTest, + rollsBackBytesAndReferenceIndexesAfterValidationErrors) { + const std::vector> failures{ + {folly::dynamic::array( + "transient", folly::dynamic::object("scratch", "\xed\xa0\x80")), + Error::InvalidUnicode}, + {folly::dynamic::array( + "transient", + folly::dynamic::object("scratch", true), + folly::dynamic::object(7, nullptr)), + Error::NonStringKey}}; + for (const auto& [input, error] : failures) { + TokenStreamWriter writer; + ASSERT_TRUE(writer.write("seed").hasValue()); + auto failed = writer.write(input); + ASSERT_TRUE(failed.hasError()); + EXPECT_EQ(failed.error(), error); + auto recovered = writer.write("transient"); + ASSERT_TRUE(recovered.hasValue()); + EXPECT_EQ(*recovered, (ValueRange{11, 11})); + ASSERT_TRUE( + writer.write(folly::dynamic::object("transient", "seed")).hasValue()); + ASSERT_TRUE(writer.write("scratch").hasValue()); + ASSERT_TRUE(writer.write("scratch").hasValue()); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + + const std::vector expected{ + 0x42, 0x54, 0x53, 0x01, 0x03, 0x0a, 0x04, 's', 'e', 'e', + 'd', 0x0a, 0x09, 't', 'r', 'a', 'n', 's', 'i', 'e', + 'n', 't', 0x01, 0x0d, 0x01, 0x0b, 0x00, 0x02, 0x0a, 0x07, + 's', 'c', 'r', 'a', 't', 'c', 'h', 0x0b, 0x02, 0x04}; + EXPECT_EQ(*document, expected); + } +} + +TEST(TokenStreamWriterTest, rollsBackBytesAndReferenceIndexesAfterSizeFailure) { + TokenStreamWriter writer(32); + ASSERT_TRUE(writer.write("seed").hasValue()); + auto failed = + writer.write(folly::dynamic::array("transient", std::string(64, 'x'))); + ASSERT_TRUE(failed.hasError()); + EXPECT_EQ(failed.error(), Error::SizeLimitExceeded); + auto recovered = writer.write("transient"); + ASSERT_TRUE(recovered.hasValue()); + EXPECT_EQ(*recovered, (ValueRange{11, 11})); + ASSERT_TRUE(writer.write("transient").hasValue()); + ASSERT_TRUE(writer.write("seed").hasValue()); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + + const std::vector expected{0x42, 0x54, 0x53, 0x01, 0x03, 0x0a, 0x04, + 's', 'e', 'e', 'd', 0x0a, 0x09, 't', + 'r', 'a', 'n', 's', 'i', 'e', 'n', + 't', 0x0b, 0x01, 0x0b, 0x00, 0x04}; + EXPECT_EQ(*document, expected); +} + +TEST(TokenStreamWriterTest, failsSafelyBelowMinimumDocumentSize) { + for (size_t limit = 0; limit < 6; ++limit) { + SCOPED_TRACE(limit); + TokenStreamWriter writer(limit); + auto value = writer.write(nullptr); + ASSERT_TRUE(value.hasError()); + EXPECT_EQ(value.error(), Error::SizeLimitExceeded); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasError()); + EXPECT_EQ(document.error(), Error::SizeLimitExceeded); + } +} + +TEST(TokenStreamWriterTest, reservesClosingArrayByteWithinSizeLimit) { + TokenStreamWriter writer(6); + auto value = writer.write(nullptr); + ASSERT_TRUE(value.hasError()); + EXPECT_EQ(value.error(), Error::SizeLimitExceeded); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ( + *document, (std::vector{0x42, 0x54, 0x53, 0x01, 0x03, 0x04})); +} + +TEST(TokenStreamWriterTest, acceptsExactSizeLimitAndRejectsNextAppend) { + TokenStreamWriter writer(7); + auto first = writer.write(nullptr); + ASSERT_TRUE(first.hasValue()); + EXPECT_EQ(*first, (ValueRange{5, 1})); + auto second = writer.write(false); + ASSERT_TRUE(second.hasError()); + EXPECT_EQ(second.error(), Error::SizeLimitExceeded); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + EXPECT_EQ( + *document, + (std::vector{0x42, 0x54, 0x53, 0x01, 0x03, 0x05, 0x04})); +} + +TEST(TokenStreamWriterTest, includesNestedContainerClosingBytesInSizeLimit) { + for (const size_t limit : {7, 8}) { + TokenStreamWriter writer(limit); + auto value = writer.write(folly::dynamic::array()); + if (limit == 7) { + ASSERT_TRUE(value.hasError()); + EXPECT_EQ(value.error(), Error::SizeLimitExceeded); + } else { + ASSERT_TRUE(value.hasValue()); + EXPECT_EQ(*value, (ValueRange{5, 2})); + } + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + const auto expected = limit == 7 + ? std::vector{0x42, 0x54, 0x53, 0x01, 0x03, 0x04} + : std::vector{0x42, 0x54, 0x53, 0x01, 0x03, 0x03, 0x04, 0x04}; + EXPECT_EQ(*document, expected); + } +} + +TEST(TokenStreamWriterTest, rejectsWriteAndFinishAfterFinishing) { + TokenStreamWriter writer; + ASSERT_TRUE(writer.write(true).hasValue()); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + auto writeAgain = writer.write(false); + ASSERT_TRUE(writeAgain.hasError()); + EXPECT_EQ(writeAgain.error(), Error::AlreadyFinished); + auto finishAgain = std::move(writer).finish(); + ASSERT_TRUE(finishAgain.hasError()); + EXPECT_EQ(finishAgain.error(), Error::AlreadyFinished); + EXPECT_EQ( + *document, + (std::vector{0x42, 0x54, 0x53, 0x01, 0x03, 0x07, 0x04})); +} + +TEST(TokenStreamWriterTest, writesDeepMixedInputWithoutRecursion) { + constexpr size_t depth = 10000; + auto input = folly::dynamic(nullptr); + auto* current = &input; + for (size_t level = depth; level > 0; --level) { + if ((level - 1) % 2 == 0) { + *current = folly::dynamic::array(); + current->push_back(nullptr); + current = &(*current)[0]; + } else { + *current = folly::dynamic::object(); + current = &(*current)["child"]; + } + } + + TokenStreamWriter writer; + auto range = writer.write(input); + // Dismantle the input before assertions so its destructor does not recurse. + while (input.isArray() || input.isObject()) { + auto child = + input.isArray() ? std::move(input[0]) : std::move(input["child"]); + input = std::move(child); + } + ASSERT_TRUE(range.hasValue()); + auto document = std::move(writer).finish(); + ASSERT_TRUE(document.hasValue()); + + std::vector expected{0x42, 0x54, 0x53, 0x01, 0x03}; + bool firstName = true; + for (size_t level = depth; level > 0; --level) { + if ((level - 1) % 2 == 0) { + expected.push_back(0x03); + } else { + expected.push_back(0x01); + if (firstName) { + expected.insert(expected.end(), {0x0c, 0x05, 'c', 'h', 'i', 'l', 'd'}); + firstName = false; + } else { + expected.insert(expected.end(), {0x0d, 0x00}); + } + } + } + expected.push_back(0x05); + for (size_t level = 0; level < depth; ++level) { + expected.push_back(level % 2 == 0 ? 0x04 : 0x02); + } + expected.push_back(0x04); + EXPECT_EQ(*document, expected); + EXPECT_EQ( + *range, (ValueRange{5, static_cast(expected.size() - 6)})); +} + +} // namespace facebook::react diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index 912089515add..de44d8ec6785 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -2415,6 +2415,30 @@ class facebook::react::SwitchProps : public facebook::react::HostPlatformViewPro public virtual folly::dynamic getDiffProps(const facebook::react::Props* prevProps) const override; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api index 20cae3adff31..f0620155f27a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api @@ -4075,6 +4075,30 @@ class facebook::react::TimerManager { public ~TimerManager() noexcept; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 34b1da7689bb..b0889cf76750 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -2408,6 +2408,30 @@ class facebook::react::SwitchProps : public facebook::react::HostPlatformViewPro public virtual folly::dynamic getDiffProps(const facebook::react::Props* prevProps) const override; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 34b1da7689bb..b0889cf76750 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -2408,6 +2408,30 @@ class facebook::react::SwitchProps : public facebook::react::HostPlatformViewPro public virtual folly::dynamic getDiffProps(const facebook::react::Props* prevProps) const override; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 1c25ee627530..241f4c97504f 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -4578,6 +4578,30 @@ class facebook::react::SwitchProps : public facebook::react::HostPlatformViewPro public facebook::react::SharedColor trackColorForTrue; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index a05609a1ac4a..a74c62e7a484 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -6212,6 +6212,30 @@ class facebook::react::TimerManager { public ~TimerManager() noexcept; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index b4e52e691068..d19682999f25 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -4564,6 +4564,30 @@ class facebook::react::SwitchProps : public facebook::react::HostPlatformViewPro public facebook::react::SharedColor trackColorForTrue; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 1642838e4827..84169869f60d 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -4571,6 +4571,30 @@ class facebook::react::SwitchProps : public facebook::react::HostPlatformViewPro public facebook::react::SharedColor trackColorForTrue; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index 2ef9e05072ac..dac288d20a19 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -1158,6 +1158,30 @@ class facebook::react::StateUpdate { public using FailureCallback = std::function; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api index 000f1f54c2ce..c9c0e6e49f53 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api @@ -2580,6 +2580,30 @@ class facebook::react::TimerManager { public ~TimerManager() noexcept; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index 9279765a5038..b79d654f6238 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -1151,6 +1151,30 @@ class facebook::react::StateUpdate { public using FailureCallback = std::function; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index 9279765a5038..b79d654f6238 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -1151,6 +1151,30 @@ class facebook::react::StateUpdate { public using FailureCallback = std::function; } +class facebook::react::TokenStreamWriter { + public TokenStreamWriter(const facebook::react::TokenStreamWriter&) = delete; + public TokenStreamWriter(facebook::react::TokenStreamWriter&&) = delete; + public TokenStreamWriter(size_t maxBytes = std::numeric_limits::max()); + public facebook::react::TokenStreamWriter& operator=(const facebook::react::TokenStreamWriter&) = delete; + public facebook::react::TokenStreamWriter& operator=(facebook::react::TokenStreamWriter&&) = delete; + public folly::Expected write(const folly::dynamic& value); + public folly::Expected, facebook::react::TokenStreamWriter::Error> finish(); + public ~TokenStreamWriter() = default; +} + +enum facebook::react::TokenStreamWriter::Error : uint8_t { + AlreadyFinished, + InvalidUnicode, + NonStringKey, + SizeLimitExceeded, +} + +struct facebook::react::TokenStreamWriter::ValueRange { + public bool operator==(const facebook::react::TokenStreamWriter::ValueRange&) const = default; + public uint32_t length; + public uint32_t offset; +} + class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter { public TouchEventEmitter(facebook::react::SharedEventTarget eventTarget, facebook::react::EventDispatcher::Weak eventDispatcher); public void onClick(facebook::react::PointerEvent event) const;