From 2359e442f6193133b8cbf6a646ae9dfc4bc68c58 Mon Sep 17 00:00:00 2001 From: NathanFallet Date: Sat, 28 Feb 2026 17:41:50 +0100 Subject: [PATCH 1/4] reproduce the issue --- gradle/libs.versions.toml | 6 +-- .../nextjs/rsc/FlightPayloadResolverTest.kt | 50 +++++++++++++++++++ .../dev/kdriver/nextjs/rsc/RowParserTest.kt | 30 +++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5b93a1d..f5004f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] -kotlin = "2.1.21" +kotlin = "2.3.0" kover = "0.8.3" detekt = "1.23.8" dokka = "2.0.0" -ksp = "2.1.21-2.0.2" +ksp = "2.3.4" maven = "0.30.0" -kdriver = "0.5.0" +kdriver = "0.5.5" mockk = "1.13.12" jsoup = "1.16.2" coroutines-test = "1.9.0" diff --git a/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolverTest.kt b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolverTest.kt index 1456d04..ae2b32a 100644 --- a/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolverTest.kt +++ b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolverTest.kt @@ -385,6 +385,56 @@ class FlightPayloadResolverTest { assertEquals("Custom: Custom data", customRow.value) } + /** + * Working case: T row with single-line text referenced via direct hex ref ($fe). + * Mirrors Vinted's RSC structure where description is in a T row. + */ + @Test + fun `test direct hex reference to T row with single-line text`() { + val text = "Simple description without newlines" + val hexLen = text.toByteArray().size.toString(16) + val payload = "0:{\"description\":\"\$fe\"}\nfe:T$hexLen,$text" + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val rows = resolver.getAllRows() + val textRow = rows["fe"] + assertNotNull(textRow) + assertIs(textRow) + assertEquals(text, textRow.value) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + assertEquals(text, result.jsonObject["description"]?.jsonPrimitive?.content) + } + + /** + * Failing case: T row with multiline text referenced via direct hex ref ($fe). + * Reproduces the Vinted bug where only the first line of the description is returned. + * After the fix, the full multiline description should be resolved correctly. + */ + @Test + fun `test direct hex reference to T row with multiline text`() { + val text = "First line\nSecond line\nThird line" + val hexLen = text.toByteArray().size.toString(16) // = "21" + val payload = "0:{\"description\":\"\$fe\"}\nfe:T$hexLen,$text" + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val rows = resolver.getAllRows() + val textRow = rows["fe"] + assertNotNull(textRow) + assertIs(textRow) + // Should contain the full multiline text, not just "First line" + assertEquals(text, textRow.value) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + assertEquals(text, result.jsonObject["description"]?.jsonPrimitive?.content) + } + /** * Helper to create JsonArray of pushes from payload string. */ diff --git a/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/RowParserTest.kt b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/RowParserTest.kt index 449bf53..963fc5f 100644 --- a/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/RowParserTest.kt +++ b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/RowParserTest.kt @@ -128,4 +128,34 @@ class RowParserTest { assertNull(row.tag) assertEquals("Testing data", row.data) } + + @Test + fun `parseRows handles length-encoded T row with single-line text`() { + val text = "Simple description without newlines" + val hexLen = text.toByteArray().size.toString(16) // = "23" + val payload = "0:{\"ref\":\"\$fe\"}\nfe:T$hexLen,$text" + + val rows = RowParser.parseRows(payload) + + val feRow = rows.find { it.id == "fe" } + assertNotNull(feRow) + assertEquals('T', feRow.tag) + assertEquals("$hexLen,$text", feRow.data) + } + + @Test + fun `parseRows handles length-encoded T row with multiline text`() { + // This is the failing case: multiline T row in same payload + val text = "First line\nSecond line\nThird line" + val hexLen = text.toByteArray().size.toString(16) // = "21" + val payload = "0:{\"ref\":\"\$fe\"}\nfe:T$hexLen,$text" + + val rows = RowParser.parseRows(payload) + + val feRow = rows.find { it.id == "fe" } + assertNotNull(feRow) + assertEquals('T', feRow.tag) + // The data should contain the full multiline text, not just the first line + assertEquals("$hexLen,$text", feRow.data) + } } From 1584b193622706d73205278b254f56d506556b03 Mon Sep 17 00:00:00 2001 From: NathanFallet Date: Sat, 28 Feb 2026 17:43:59 +0100 Subject: [PATCH 2/4] gradle setup quick fix --- build.gradle.kts | 7 +++++++ settings.gradle.kts | 2 ++ 2 files changed, 9 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index eb045bc..9f87fe0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.multiplatform) apply false alias(libs.plugins.maven) apply false + alias(libs.plugins.kover) alias(libs.plugins.dokka) } @@ -20,3 +21,9 @@ allprojects { mavenCentral() } } + +dependencies { + kover(projects.nextjs) + kover(projects.nextjsEngine) + kover(projects.nextjsRsc) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 93ec21b..b55e1d0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,6 @@ rootProject.name = "kdriver-nextjs" +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + pluginManagement { repositories { mavenCentral() From 71b856cc6e746730aa9fd7039f5c5e460731aff4 Mon Sep 17 00:00:00 2001 From: NathanFallet Date: Sat, 28 Feb 2026 17:51:40 +0100 Subject: [PATCH 3/4] fix the issue --- README.md | 2 +- build.gradle.kts | 2 +- .../dev/kdriver/nextjs/rsc/RowParser.kt | 57 ++++++++++++++++++- .../nextjs/rsc/ReferenceResolverTest.kt | 25 ++++---- 4 files changed, 67 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 5592bbb..b51c830 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Add the dependency to your `build.gradle.kts`: ```kotlin dependencies { - implementation("dev.kdriver:nextjs:0.2.0") + implementation("dev.kdriver:nextjs:0.2.1") } ``` diff --git a/build.gradle.kts b/build.gradle.kts index 9f87fe0..2e2c3c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { allprojects { group = "dev.kdriver" - version = "0.2.0" + version = "0.2.1" project.ext.set("url", "https://github.com/cdpdriver/kdriver-nextjs") project.ext.set("license.name", "Apache 2.0") project.ext.set("license.url", "https://www.apache.org/licenses/LICENSE-2.0.txt") diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt index 91c3b15..41707bd 100644 --- a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt @@ -73,11 +73,64 @@ object RowParser { /** * Parses multiple row lines from a payload. * + * Handles T rows with length-encoded text (format: `id:T,`) which + * may contain literal newlines in their content. The hex length is used to read + * the exact byte count of text, skipping past any embedded newlines, rather than + * stopping at the first newline like a naive line-split would. + * * @param payload The complete payload string with newline-separated rows * @return List of successfully parsed rows */ fun parseRows(payload: String): List { - return payload.split('\n') - .mapNotNull { parseRow(it) } + val results = mutableListOf() + var pos = 0 + + while (pos < payload.length) { + // Skip blank lines + if (payload[pos] == '\n') { + pos++ + continue + } + + val lineEnd = payload.indexOf('\n', pos) + val line = if (lineEnd == -1) payload.substring(pos) else payload.substring(pos, lineEnd) + + if (line.isBlank()) { + pos = if (lineEnd == -1) payload.length else lineEnd + 1 + continue + } + + // Check if this is a length-encoded T row (may span multiple lines) + val colonIdx = line.indexOf(':') + if (colonIdx > 0) { + val rest = line.substring(colonIdx + 1) + if (rest.isNotEmpty() && rest[0] == 'T') { + val afterT = rest.substring(1) + val commaIdx = afterT.indexOf(',') + if (commaIdx > 0) { + val potentialLength = afterT.substring(0, commaIdx) + if (potentialLength.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) { + val textByteLength = potentialLength.toLong(16).toInt() + val textStart = pos + colonIdx + 1 + 1 + commaIdx + 1 // skip id + ':' + 'T' + hexLen + ',' + val remaining = payload.substring(textStart) + val remainingBytes = remaining.toByteArray(Charsets.UTF_8) + if (textByteLength <= remainingBytes.size) { + val text = String(remainingBytes, 0, textByteLength, Charsets.UTF_8) + results.add(ParsedRow(line.substring(0, colonIdx), 'T', "$potentialLength,$text")) + pos = textStart + text.length + if (pos < payload.length && payload[pos] == '\n') pos++ + continue + } + } + } + } + } + + // Regular row — parse the current line as-is + parseRow(line)?.let { results.add(it) } + pos = if (lineEnd == -1) payload.length else lineEnd + 1 + } + + return results } } diff --git a/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolverTest.kt b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolverTest.kt index f6c41c4..3fb06de 100644 --- a/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolverTest.kt +++ b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolverTest.kt @@ -1,11 +1,6 @@ package dev.kdriver.nextjs.rsc -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.* import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -20,7 +15,7 @@ class ReferenceResolverTest { ) val resolver = ReferenceResolver(rows) - val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + val result = resolver.resolve(rows["0"]!!.json) assertIs(result) assertEquals("Hello", result[0].jsonPrimitive.content) @@ -35,7 +30,7 @@ class ReferenceResolverTest { ) val resolver = ReferenceResolver(rows) - val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + val result = resolver.resolve(rows["0"]!!.json) assertIs(result) assertEquals("Final value", result.content) @@ -49,7 +44,7 @@ class ReferenceResolverTest { ) val resolver = ReferenceResolver(rows) - val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + val result = resolver.resolve(rows["0"]!!.json) assertIs(result) assertEquals("Async result", result.content) @@ -63,7 +58,7 @@ class ReferenceResolverTest { ) val resolver = ReferenceResolver(rows) - val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + val result = resolver.resolve(rows["0"]!!.json) assertIs(result) assertEquals("Hex ref value", result.content) @@ -91,7 +86,7 @@ class ReferenceResolverTest { ) val resolver = ReferenceResolver(rows) - val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + val result = resolver.resolve(rows["0"]!!.json) assertIs(result) // First element should be null (undefined) @@ -108,7 +103,7 @@ class ReferenceResolverTest { ) val resolver = ReferenceResolver(rows) - val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + val result = resolver.resolve(rows["0"]!!.json) // Should resolve the nested reference assertIs(result) @@ -125,7 +120,7 @@ class ReferenceResolverTest { ) val resolver = ReferenceResolver(rows) - val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + val result = resolver.resolve(rows["0"]!!.json) assertIs(result) assertEquals(2, result.size) @@ -140,7 +135,7 @@ class ReferenceResolverTest { ) val resolver = ReferenceResolver(rows) - val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + val result = resolver.resolve(rows["0"]!!.json) assertIs(result) assert(result.content.contains("not found")) @@ -197,7 +192,7 @@ class ReferenceResolverTest { ) val resolver = ReferenceResolver(rows, maxDepth = 10) - val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + val result = resolver.resolve(rows["0"]!!.json) // Should not crash, should return something assertIs(result) From d406dea43b122ef09a5024d66d811b64a7a85065 Mon Sep 17 00:00:00 2001 From: NathanFallet Date: Sat, 28 Feb 2026 18:06:41 +0100 Subject: [PATCH 4/4] fix multiplatform compilation --- gradle/libs.versions.toml | 2 ++ nextjs-rsc/build.gradle.kts | 1 + .../kotlin/dev/kdriver/nextjs/rsc/ReferenceResolver.kt | 9 ++------- .../kotlin/dev/kdriver/nextjs/rsc/RowParser.kt | 6 ++++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5004f2..a8e3292 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ dokka = "2.0.0" ksp = "2.3.4" maven = "0.30.0" kdriver = "0.5.5" +ktor = "3.4.0" mockk = "1.13.12" jsoup = "1.16.2" coroutines-test = "1.9.0" @@ -22,6 +23,7 @@ maven = { id = "com.vanniktech.maven.publish", version.ref = "maven" } [libraries] kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +ktor-io = { module = "io.ktor:ktor-io", version.ref = "ktor" } kdriver-core = { group = "dev.kdriver", name = "core", version.ref = "kdriver" } tests-mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } tests-jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } diff --git a/nextjs-rsc/build.gradle.kts b/nextjs-rsc/build.gradle.kts index 2ff8e77..7549778 100644 --- a/nextjs-rsc/build.gradle.kts +++ b/nextjs-rsc/build.gradle.kts @@ -85,6 +85,7 @@ kotlin { val commonMain by getting { dependencies { api(libs.kotlinx.serialization.json) + api(libs.ktor.io) } } val jvmTest by getting { diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolver.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolver.kt index 9788d87..802142e 100644 --- a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolver.kt +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolver.kt @@ -1,10 +1,6 @@ package dev.kdriver.nextjs.rsc -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.* /** * Resolves RSC references in JSON elements. @@ -26,7 +22,7 @@ import kotlinx.serialization.json.JsonPrimitive */ class ReferenceResolver( private val rows: Map, - private val maxDepth: Int = 100 + private val maxDepth: Int = 100, ) { /** @@ -46,7 +42,6 @@ class ReferenceResolver( is JsonPrimitive -> resolveReference(element, depth) is JsonArray -> JsonArray(element.map { resolve(it, depth + 1) }) is JsonObject -> JsonObject(element.mapValues { resolve(it.value, depth + 1) }) - else -> element } } diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt index 41707bd..0c270ee 100644 --- a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt @@ -1,5 +1,7 @@ package dev.kdriver.nextjs.rsc +import io.ktor.utils.io.core.* + /** * Parses RSC row format: `id:tag:data` or `id:data` (no tag). * @@ -113,9 +115,9 @@ object RowParser { val textByteLength = potentialLength.toLong(16).toInt() val textStart = pos + colonIdx + 1 + 1 + commaIdx + 1 // skip id + ':' + 'T' + hexLen + ',' val remaining = payload.substring(textStart) - val remainingBytes = remaining.toByteArray(Charsets.UTF_8) + val remainingBytes = remaining.toByteArray() if (textByteLength <= remainingBytes.size) { - val text = String(remainingBytes, 0, textByteLength, Charsets.UTF_8) + val text = remainingBytes.decodeToString(0, textByteLength) results.add(ParsedRow(line.substring(0, colonIdx), 'T', "$potentialLength,$text")) pos = textStart + text.length if (pos < payload.length && payload[pos] == '\n') pos++