From c3ed5040215ca88d89e2e77f6e5bde6ef7dc525c Mon Sep 17 00:00:00 2001 From: Shreyansh Lodha Date: Mon, 25 May 2026 23:50:25 +0530 Subject: [PATCH 1/5] Add layout tree source hint enrichment --- docs/snapshot-schema.md | 6 + .../agentpreview/AgentPreviewPlugin.kt | 2 + .../agentpreview/model/SnapshotLayoutNode.kt | 4 + .../AndroidComposeRendererInRobolectric.kt | 180 +++++++++++++++++- .../model/SnapshotSerializationTest.kt | 8 + ...AndroidComposeRendererInRobolectricTest.kt | 88 ++++++++- 6 files changed, 278 insertions(+), 10 deletions(-) diff --git a/docs/snapshot-schema.md b/docs/snapshot-schema.md index ec4e8a6..7960154 100644 --- a/docs/snapshot-schema.md +++ b/docs/snapshot-schema.md @@ -62,6 +62,10 @@ Version 2 adds preview parameter metadata, optional layout tree data, and option "boundsPx": { "x": 0, "y": 0, "width": 393, "height": 852 }, "boundsDp": { "x": 0.0, "y": 0.0, "width": 393.0, "height": 852.0 }, "componentHint": "androidx.compose.foundation.layout.Column", + "sourceName": "LoginCard", + "sourceFile": "LoginPreview.kt", + "sourceLine": 24, + "sourceHintKind": "tooling-ancestor-node-identity", "modifierHint": "androidx.compose.ui.Modifier", "classHint": "androidx.compose.ui.node.LayoutNode", "semanticsId": "7", @@ -94,3 +98,5 @@ Future desktop and web renderers should use separate platform-specific viewport ## Semantics and Layout Tree Fake renderer snapshots emit an empty `nodes` list because no real Compose semantics tree is available. Production renderer snapshots populate `nodes` from Compose semantics when available and may include `layoutTree` entries derived from the rendered Compose layout hierarchy. + +`layoutTree` entries always keep `componentHint` as the implementation-level fallback. Production Android rendering may also add nullable best-effort source hints from Compose tooling data: `sourceName`, `sourceFile`, `sourceLine`, and `sourceHintKind`. These fields are optional, depend on Compose tooling/source information being available at render time, and are omitted if enrichment fails. diff --git a/plugin/src/main/kotlin/dev/staticvar/agentpreview/AgentPreviewPlugin.kt b/plugin/src/main/kotlin/dev/staticvar/agentpreview/AgentPreviewPlugin.kt index f56dc28..82165f5 100644 --- a/plugin/src/main/kotlin/dev/staticvar/agentpreview/AgentPreviewPlugin.kt +++ b/plugin/src/main/kotlin/dev/staticvar/agentpreview/AgentPreviewPlugin.kt @@ -109,6 +109,7 @@ class AgentPreviewPlugin : Plugin { project, project.configurations.detachedConfiguration( project.dependencies.create("androidx.compose.ui:ui-tooling:1.11.2"), + project.dependencies.create("androidx.compose.ui:ui-tooling-data:1.11.2"), ), ) @@ -117,6 +118,7 @@ class AgentPreviewPlugin : Plugin { project, project.configurations.detachedConfiguration( project.dependencies.create("androidx.compose.ui:ui-tooling:1.11.2"), + project.dependencies.create("androidx.compose.ui:ui-tooling-data:1.11.2"), project.dependencies.create("androidx.test:core:1.7.0"), project.dependencies.create("androidx.test:monitor:1.8.0"), ), diff --git a/plugin/src/main/kotlin/dev/staticvar/agentpreview/model/SnapshotLayoutNode.kt b/plugin/src/main/kotlin/dev/staticvar/agentpreview/model/SnapshotLayoutNode.kt index d645140..b158a47 100644 --- a/plugin/src/main/kotlin/dev/staticvar/agentpreview/model/SnapshotLayoutNode.kt +++ b/plugin/src/main/kotlin/dev/staticvar/agentpreview/model/SnapshotLayoutNode.kt @@ -13,6 +13,10 @@ data class SnapshotLayoutNode( val boundsPx: Bounds, val boundsDp: DpBounds, val componentHint: String? = null, + val sourceName: String? = null, + val sourceFile: String? = null, + val sourceLine: Int? = null, + val sourceHintKind: String? = null, val modifierHint: String? = null, val classHint: String? = null, val semanticsId: String? = null, diff --git a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt index 0dcf3f4..0effac0 100644 --- a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt +++ b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt @@ -49,10 +49,17 @@ object AndroidComposeRendererInRobolectric { setNoActionBarTheme(activity) controller.javaClass.getMethod("setup").invoke(controller) applyConfiguration(activity, density, fontScale ?: DEFAULT_FONT_SCALE, locale, uiMode) - setContent(activity, className, methodName, previewParameterProviderClassName, previewParameterIndex) + val toolingRecord = ToolingCompositionRecord.createOrNull() + setContent(activity, className, methodName, previewParameterProviderClassName, previewParameterIndex, toolingRecord) val view = draw(activity, widthPx.coerceAtLeast(1), heightPx.coerceAtLeast(1), outputFile, showBackground, backgroundColor) writeSemantics(view, semanticsOutputFile, includeUnmergedSemantics) - writeLayoutTree(view, layoutTreeOutputFile, density) + writeLayoutTree( + view, + layoutTreeOutputFile, + density, + toolingRecord, + PreviewSourceFallback(className = className, methodName = methodName), + ) } private fun applyConfiguration( @@ -126,8 +133,10 @@ object AndroidComposeRendererInRobolectric { methodName: String, previewParameterProviderClassName: String?, previewParameterIndex: Int?, + toolingRecord: ToolingCompositionRecord?, ) { - val content = PreviewComposable(className, methodName, previewParameterProviderClassName, previewParameterIndex) + val previewContent = PreviewComposable(className, methodName, previewParameterProviderClassName, previewParameterIndex) + val content = toolingRecord?.wrap(previewContent) ?: previewContent val ownerClass = Class.forName("androidx.activity.ComponentActivity") val setContent = Class @@ -208,10 +217,12 @@ object AndroidComposeRendererInRobolectric { contentRoot: Any, outputFile: File, density: Float, + toolingRecord: ToolingCompositionRecord? = null, + previewSourceFallback: PreviewSourceFallback? = null, ) { runCatching { val composeView = checkNotNull(findComposeView(contentRoot)) { "Compose root view was not found." } - writeLayoutTreeFromComposeView(composeView, outputFile, density) + writeLayoutTreeFromComposeView(composeView, outputFile, density, toolingRecord, previewSourceFallback) }.getOrElse { throwable -> writeEmptyLayoutTreeWarning(outputFile, throwable) } @@ -221,6 +232,8 @@ object AndroidComposeRendererInRobolectric { composeView: Any, outputFile: File, density: Float, + toolingRecord: ToolingCompositionRecord? = null, + previewSourceFallback: PreviewSourceFallback? = null, ) { runCatching { val getRoot = @@ -232,7 +245,14 @@ object AndroidComposeRendererInRobolectric { } ?: error("Compose root view ${composeView.javaClass.name} does not expose getRoot().") val rootLayoutNode = getRoot.invoke(composeView) - val nodes = listOf(extractLayoutTree(rootLayoutNode, density)) + val sourceHints = toolingRecord?.sourceHintsOrEmpty().orEmpty() + val fallbackHints = + if (sourceHints.isEmpty() && previewSourceFallback != null) { + mapOf(System.identityHashCode(rootLayoutNode) to previewSourceFallback.toLayoutTreeSourceHint()) + } else { + emptyMap() + } + val nodes = listOf(extractLayoutTree(rootLayoutNode, density, sourceHints + fallbackHints)) outputFile.writeText(Json.encodeToString(ListSerializer(SnapshotLayoutNode.serializer()), nodes)) }.getOrElse { throwable -> writeEmptyLayoutTreeWarning(outputFile, throwable) @@ -254,7 +274,28 @@ object AndroidComposeRendererInRobolectric { internal fun extractLayoutTree( rootLayoutNode: Any, density: Float, - ): SnapshotLayoutNode = toSnapshotLayoutNode(rootLayoutNode, density, nextLayoutId()) + sourceHints: Map = emptyMap(), + ): SnapshotLayoutNode = toSnapshotLayoutNode(rootLayoutNode, density, nextLayoutId(), sourceHints) + + internal data class LayoutTreeSourceHint( + val sourceName: String? = null, + val sourceFile: String? = null, + val sourceLine: Int? = null, + val sourceHintKind: String? = null, + ) + + internal data class PreviewSourceFallback( + val className: String, + val methodName: String, + ) { + fun toLayoutTreeSourceHint(): LayoutTreeSourceHint = + LayoutTreeSourceHint( + sourceName = methodName, + sourceFile = "${className.substringAfterLast('$').substringAfterLast('.').removeSuffix("Kt")}.kt", + sourceLine = 1, + sourceHintKind = "preview-entrypoint-fallback", + ) + } private fun nextLayoutId(): Iterator = generateSequence(1) { it + 1 }.map { id -> "layout-$id" }.iterator() @@ -322,18 +363,24 @@ object AndroidComposeRendererInRobolectric { node: Any, density: Float, ids: Iterator, + sourceHints: Map, ): SnapshotLayoutNode { val id = ids.next() val boundsPx = layoutBounds(node) val semantics = semanticsProperties(method(node, "getSemanticsConfiguration")?.invoke(node)) - val children = layoutChildren(node).map { child -> toSnapshotLayoutNode(child, density, ids) } + val children = layoutChildren(node).map { child -> toSnapshotLayoutNode(child, density, ids, sourceHints) } val measurePolicy = method(node, "getMeasurePolicy")?.invoke(node) val modifier = method(node, "getModifier")?.invoke(node) + val sourceHint = sourceHints[System.identityHashCode(node)] return SnapshotLayoutNode( id = id, boundsPx = boundsPx, boundsDp = boundsPx.toDpBounds(density), componentHint = measurePolicy?.javaClass?.name ?: node.javaClass.name, + sourceName = sourceHint?.sourceName, + sourceFile = sourceHint?.sourceFile, + sourceLine = sourceHint?.sourceLine, + sourceHintKind = sourceHint?.sourceHintKind, modifierHint = modifier?.javaClass?.name ?: modifier?.toString(), classHint = node.javaClass.name, semanticsId = method(node, "getSemanticsId")?.invoke(node)?.toString(), @@ -502,6 +549,125 @@ object AndroidComposeRendererInRobolectric { field.set(target, value) } + internal class ToolingCompositionRecord private constructor( + private val record: Any, + private val inspectableMethod: java.lang.reflect.Method, + ) { + fun wrap(content: Function2): Function2 = + object : Function2 { + private var warningLogged = false + + override fun invoke( + composer: Any?, + changed: Int, + ) { + runCatching { + inspectableMethod.invoke(null, record, content, composer, changed) + }.getOrElse { throwable -> + if (!warningLogged) { + warningLogged = true + warnSourceHintsDisabled("failed to invoke Compose tooling Inspectable wrapper", throwable) + } + content.invoke(composer, changed) + } + } + } + + fun sourceHintsOrEmpty(): Map = + runCatching { sourceHints() }.getOrElse { throwable -> + warnSourceHintsDisabled("failed to read Compose tooling composition data", throwable) + emptyMap() + } + + private fun sourceHints(): Map { + @Suppress("UNCHECKED_CAST") + val store = method(record, "getStore")?.invoke(record) as? Iterable<*> ?: return emptyMap() + val asTree = Class.forName("androidx.compose.ui.tooling.data.SlotTreeKt").getMethod("asTree", compositionDataClass()) + return buildMap { + store.filterNotNull().forEach { compositionData -> + val rootGroup = asTree.invoke(null, compositionData) + collectGroupSourceHints(rootGroup, nearestHint = null, hints = this) + } + } + } + + companion object { + fun createOrNull(): ToolingCompositionRecord? = + runCatching { + val recordClass = Class.forName("androidx.compose.ui.tooling.CompositionDataRecord") + val companion = recordClass.getField("Companion").get(null) + val record = companion.javaClass.getMethod("create").invoke(companion) + val inspectableMethod = + Class + .forName("androidx.compose.ui.tooling.InspectableKt") + .getMethod( + "Inspectable", + recordClass, + Function2::class.java, + compositionComposerClass(), + Int::class.javaPrimitiveType, + ) + ToolingCompositionRecord(record, inspectableMethod) + }.getOrElse { throwable -> + warnSourceHintsDisabled("Compose tooling APIs are unavailable", throwable) + null + } + + private fun compositionDataClass(): Class<*> = Class.forName("androidx.compose.runtime.tooling.CompositionData") + + private fun compositionComposerClass(): Class<*> = Class.forName("androidx.compose.runtime.Composer") + } + } + + private fun collectGroupSourceHints( + group: Any, + nearestHint: LayoutTreeSourceHint?, + hints: MutableMap, + ) { + val ownName = method(group, "getName")?.invoke(group) as? String + val ownLocation = method(group, "getLocation")?.invoke(group) + val ownHint = sourceHint(ownName, ownLocation, "tooling-node-identity") + val currentHint = ownHint ?: nearestHint + val node = method(group, "getNode")?.invoke(group) + if (node != null && currentHint != null) { + hints[System.identityHashCode(node)] = + if (ownHint != null) { + currentHint + } else { + currentHint.copy(sourceHintKind = "tooling-ancestor-node-identity") + } + } + @Suppress("UNCHECKED_CAST") + val children = method(group, "getChildren")?.invoke(group) as? Iterable<*> ?: return + children.filterNotNull().forEach { child -> collectGroupSourceHints(child, currentHint, hints) } + } + + private fun sourceHint( + sourceName: String?, + location: Any?, + sourceHintKind: String, + ): LayoutTreeSourceHint? { + val sourceFile = location?.let { method(it, "getSourceFile")?.invoke(it) as? String } + val sourceLine = location?.let { method(it, "getLineNumber")?.invoke(it) as? Int }?.takeIf { it > 0 } + return LayoutTreeSourceHint( + sourceName = sourceName, + sourceFile = sourceFile, + sourceLine = sourceLine, + sourceHintKind = sourceHintKind, + ).takeIf { it.sourceName != null || it.sourceFile != null || it.sourceLine != null } + } + + private fun warnSourceHintsDisabled( + message: String, + throwable: Throwable, + ) { + System.err.println( + "AgentPreview: optional Compose layout source hints disabled; $message. " + + "Layout tree extraction will continue without source hints. " + + "Cause: ${throwable.javaClass.name}: ${throwable.message}", + ) + } + private const val DENSITY_DEFAULT = 160 private const val PNG_QUALITY = 100 private const val DEFAULT_FONT_SCALE = 1.0f diff --git a/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt b/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt index a75f663..3f3761b 100644 --- a/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt +++ b/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt @@ -82,6 +82,10 @@ class SnapshotSerializationTest { boundsPx = Bounds(x = 8, y = 12, width = 40, height = 20), boundsDp = DpBounds(x = 4.0f, y = 6.0f, width = 20.0f, height = 10.0f), componentHint = "androidx.compose.foundation.layout.RowMeasurePolicy", + sourceName = "LoginButton", + sourceFile = "LoginPreview.kt", + sourceLine = 42, + sourceHintKind = "tooling-ancestor-node-identity", modifierHint = "androidx.compose.ui.Modifier", classHint = "androidx.compose.ui.node.LayoutNode", semanticsId = "7", @@ -104,6 +108,10 @@ class SnapshotSerializationTest { assertTrue(encoded.contains("\"boundsPx\"")) assertTrue(encoded.contains("\"boundsDp\"")) assertTrue(encoded.contains("\"componentHint\"")) + assertTrue(encoded.contains("\"sourceName\"")) + assertTrue(encoded.contains("\"sourceFile\"")) + assertTrue(encoded.contains("\"sourceLine\": 42")) + assertTrue(encoded.contains("\"sourceHintKind\"")) assertEquals(snapshot, json.decodeFromString(PreviewSnapshot.serializer(), encoded)) } diff --git a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt index d45f16c..8300d24 100644 --- a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt +++ b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt @@ -191,6 +191,76 @@ class AndroidComposeRendererInRobolectricTest { ) } + @Test + fun `extracts layout tree source hints when tooling identity matches runtime node`() { + val child = + FakeLayoutNode( + semanticsId = 7, + coordinates = FakeCoordinates(width = 40, height = 20, rootX = 10.0f, rootY = 6.0f), + measurePolicy = FakeRowMeasurePolicy(), + modifier = FakeModifier("padding"), + semanticsConfiguration = FakeSemanticsConfiguration(), + ) + val root = + FakeLayoutNode( + semanticsId = 1, + coordinates = FakeCoordinates(width = 100, height = 50, rootX = 0.0f, rootY = 0.0f), + measurePolicy = FakeColumnMeasurePolicy(), + modifier = FakeModifier("fillMaxSize"), + semanticsConfiguration = FakeSemanticsConfiguration(), + children = listOf(child), + ) + val sourceHints = + mapOf( + System.identityHashCode(child) to + AndroidComposeRendererInRobolectric.LayoutTreeSourceHint( + sourceName = "LoginButton", + sourceFile = "LoginPreview.kt", + sourceLine = 42, + sourceHintKind = "tooling-ancestor-node-identity", + ), + ) + + val tree = AndroidComposeRendererInRobolectric.extractLayoutTree(root, density = 2.0f, sourceHints = sourceHints) + + val enriched = tree.children.single() + assertEquals("LoginButton", enriched.sourceName) + assertEquals("LoginPreview.kt", enriched.sourceFile) + assertEquals(42, enriched.sourceLine) + assertEquals("tooling-ancestor-node-identity", enriched.sourceHintKind) + assertTrue(enriched.componentHint.orEmpty().contains("FakeRowMeasurePolicy")) + } + + @Test + fun `preview source fallback can enrich root layout node when tooling hints are absent`() { + val outputFile = tempDir.resolve("layout-tree.json") + val root = + FakeLayoutNode( + semanticsId = 1, + coordinates = FakeCoordinates(width = 100, height = 50, rootX = 0.0f, rootY = 0.0f), + measurePolicy = FakeColumnMeasurePolicy(), + modifier = FakeModifier("fillMaxSize"), + semanticsConfiguration = FakeSemanticsConfiguration(), + ) + + AndroidComposeRendererInRobolectric.writeLayoutTreeFromComposeView( + ComposeRoot(root), + outputFile, + density = 2.0f, + previewSourceFallback = + AndroidComposeRendererInRobolectric.PreviewSourceFallback( + className = "dev.example.LoginPreviewKt", + methodName = "LoginPreview", + ), + ) + + val tree = outputFile.readText() + assertTrue(tree.contains("\"sourceName\":\"LoginPreview\""), tree) + assertTrue(tree.contains("\"sourceFile\":\"LoginPreview.kt\""), tree) + assertTrue(tree.contains("\"sourceLine\":1"), tree) + assertTrue(tree.contains("\"sourceHintKind\":\"preview-entrypoint-fallback\""), tree) + } + @Test fun `missing compose root writes empty layout tree sidecar and warns`() { val outputFile = tempDir.resolve("layout-tree.json") @@ -198,9 +268,15 @@ class AndroidComposeRendererInRobolectricTest { val warning = captureStderr { AndroidComposeRendererInRobolectric::class.java - .getDeclaredMethod("writeLayoutTree", Any::class.java, File::class.java, Float::class.javaPrimitiveType) - .apply { isAccessible = true } - .invoke(AndroidComposeRendererInRobolectric, ViewWithoutComposeRoot(), outputFile, 2.0f) + .getDeclaredMethod( + "writeLayoutTree", + Any::class.java, + File::class.java, + Float::class.javaPrimitiveType, + AndroidComposeRendererInRobolectric.ToolingCompositionRecord::class.java, + AndroidComposeRendererInRobolectric.PreviewSourceFallback::class.java, + ).apply { isAccessible = true } + .invoke(AndroidComposeRendererInRobolectric, ViewWithoutComposeRoot(), outputFile, 2.0f, null, null) } assertEquals("[]", outputFile.readText()) @@ -262,6 +338,12 @@ class AndroidComposeRendererInRobolectricTest { private class ViewWithoutComposeRoot + private class ComposeRoot( + private val root: Any, + ) { + fun getRoot(): Any = root + } + private class ComposeRootWithBrokenLayoutNode { fun getRoot(): Any = error("boom") } From 0b2a792e497dc1c2d11c815c50e6cc6047487334 Mon Sep 17 00:00:00 2001 From: Shreyansh Lodha Date: Mon, 25 May 2026 23:57:12 +0530 Subject: [PATCH 2/5] Fix layout source hint correlation --- docs/snapshot-schema.md | 2 +- .../AndroidComposeRendererInRobolectric.kt | 111 +++++++++++++++--- ...AndroidComposeRendererInRobolectricTest.kt | 98 +++++++++++++++- 3 files changed, 195 insertions(+), 16 deletions(-) diff --git a/docs/snapshot-schema.md b/docs/snapshot-schema.md index 7960154..d21373e 100644 --- a/docs/snapshot-schema.md +++ b/docs/snapshot-schema.md @@ -99,4 +99,4 @@ Future desktop and web renderers should use separate platform-specific viewport Fake renderer snapshots emit an empty `nodes` list because no real Compose semantics tree is available. Production renderer snapshots populate `nodes` from Compose semantics when available and may include `layoutTree` entries derived from the rendered Compose layout hierarchy. -`layoutTree` entries always keep `componentHint` as the implementation-level fallback. Production Android rendering may also add nullable best-effort source hints from Compose tooling data: `sourceName`, `sourceFile`, `sourceLine`, and `sourceHintKind`. These fields are optional, depend on Compose tooling/source information being available at render time, and are omitted if enrichment fails. +`layoutTree` entries always keep `componentHint` as the implementation-level fallback. Production Android rendering may also add nullable best-effort source hints from Compose tooling data: `sourceName`, `sourceFile`, `sourceLine`, and `sourceHintKind`. These fields are optional, depend on Compose tooling/source information being available at render time, and are omitted if enrichment fails. `sourceLine` is emitted only when an actual positive source line is available; preview-entrypoint fallback hints may include `sourceName` and `sourceFile` with `sourceLine` omitted. diff --git a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt index 0effac0..62525bc 100644 --- a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt +++ b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt @@ -292,7 +292,7 @@ object AndroidComposeRendererInRobolectric { LayoutTreeSourceHint( sourceName = methodName, sourceFile = "${className.substringAfterLast('$').substringAfterLast('.').removeSuffix("Kt")}.kt", - sourceLine = 1, + sourceLine = null, sourceHintKind = "preview-entrypoint-fallback", ) } @@ -586,7 +586,7 @@ object AndroidComposeRendererInRobolectric { return buildMap { store.filterNotNull().forEach { compositionData -> val rootGroup = asTree.invoke(null, compositionData) - collectGroupSourceHints(rootGroup, nearestHint = null, hints = this) + collectGroupSourceHints(rootGroup, hints = this) } } } @@ -619,27 +619,109 @@ object AndroidComposeRendererInRobolectric { } } - private fun collectGroupSourceHints( + internal fun collectGroupSourceHints( group: Any, - nearestHint: LayoutTreeSourceHint?, hints: MutableMap, ) { + val entries = mutableListOf() + collectToolingGroupEntries(group, parentPreorder = null, depth = 0, entries = entries) + val sourceEntries = entries.filter { it.hint != null } + entries.filter { it.node != null }.forEach { nodeEntry -> + val node = nodeEntry.node ?: return@forEach + val directHint = nodeEntry.hint?.copy(sourceHintKind = "tooling-node-identity") + val ancestorHint = + sourceEntries + .asSequence() + .filter { sourceEntry -> sourceEntry.preorder in nodeEntry.ancestorPreorders } + .maxByOrNull { it.depth } + ?.hint + ?.copy(sourceHintKind = "tooling-ancestor-node-identity") + val siblingHint = + sourceEntries + .asSequence() + .filter { sourceEntry -> + sourceEntry.preorder < nodeEntry.preorder && + sourceEntry.parentPreorder == nodeEntry.parentPreorder && + sourceEntry.box != null && + nodeEntry.box != null && + sourceEntry.box.contains(nodeEntry.box) + }.maxByOrNull { it.preorder } + ?.hint + ?.copy(sourceHintKind = "tooling-sibling-preorder") + val hint = directHint ?: ancestorHint ?: siblingHint + if (hint != null) hints[System.identityHashCode(node)] = hint + } + } + + private fun collectToolingGroupEntries( + group: Any, + parentPreorder: Int?, + depth: Int, + entries: MutableList, + ancestorPreorders: List = emptyList(), + ) { + val preorder = entries.size val ownName = method(group, "getName")?.invoke(group) as? String val ownLocation = method(group, "getLocation")?.invoke(group) val ownHint = sourceHint(ownName, ownLocation, "tooling-node-identity") - val currentHint = ownHint ?: nearestHint val node = method(group, "getNode")?.invoke(group) - if (node != null && currentHint != null) { - hints[System.identityHashCode(node)] = - if (ownHint != null) { - currentHint - } else { - currentHint.copy(sourceHintKind = "tooling-ancestor-node-identity") - } - } + entries += + ToolingGroupEntry( + preorder = preorder, + parentPreorder = parentPreorder, + depth = depth, + ancestorPreorders = ancestorPreorders, + hint = ownHint, + node = node, + box = toolingGroupBox(method(group, "getBox")?.invoke(group)), + ) @Suppress("UNCHECKED_CAST") val children = method(group, "getChildren")?.invoke(group) as? Iterable<*> ?: return - children.filterNotNull().forEach { child -> collectGroupSourceHints(child, currentHint, hints) } + children.filterNotNull().forEach { child -> + collectToolingGroupEntries( + group = child, + parentPreorder = preorder, + depth = depth + 1, + entries = entries, + ancestorPreorders = ancestorPreorders + preorder, + ) + } + } + + private data class ToolingGroupEntry( + val preorder: Int, + val parentPreorder: Int?, + val depth: Int, + val ancestorPreorders: List, + val hint: LayoutTreeSourceHint?, + val node: Any?, + val box: ToolingIntRect?, + ) + + private data class ToolingIntRect( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + ) { + fun contains(other: ToolingIntRect): Boolean = + left <= other.left && top <= other.top && right >= other.right && bottom >= other.bottom + } + + private fun toolingGroupBox(box: Any?): ToolingIntRect? { + box ?: return null + val reflected = + ToolingIntRect( + left = method(box, "getLeft")?.invoke(box) as? Int ?: Int.MIN_VALUE, + top = method(box, "getTop")?.invoke(box) as? Int ?: Int.MIN_VALUE, + right = method(box, "getRight")?.invoke(box) as? Int ?: Int.MIN_VALUE, + bottom = method(box, "getBottom")?.invoke(box) as? Int ?: Int.MIN_VALUE, + ).takeIf { rect -> + rect.left != Int.MIN_VALUE && rect.top != Int.MIN_VALUE && rect.right != Int.MIN_VALUE && rect.bottom != Int.MIN_VALUE + } + if (reflected != null) return reflected + val values = INT_RECT_PATTERN.findAll(box.toString()).mapNotNull { it.value.toIntOrNull() }.toList() + return values.takeIf { it.size >= 4 }?.let { ToolingIntRect(it[0], it[1], it[2], it[3]) } } private fun sourceHint( @@ -677,4 +759,5 @@ object AndroidComposeRendererInRobolectric { private const val UI_MODE_NIGHT_NO = 0x10 private const val UI_MODE_NIGHT_YES = 0x20 private const val DEFAULT_BACKGROUND_COLOR = -0x1 + private val INT_RECT_PATTERN = Regex("-?\\d+") } diff --git a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt index 8300d24..a988947 100644 --- a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt +++ b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt @@ -231,6 +231,60 @@ class AndroidComposeRendererInRobolectricTest { assertTrue(enriched.componentHint.orEmpty().contains("FakeRowMeasurePolicy")) } + @Test + fun `correlates sibling source call group to following node group by preorder and bounds`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + children = + listOf( + FakeToolingGroup( + name = "LoginCard", + location = FakeSourceLocation("LoginPreview.kt", 42), + box = FakeIntRect(16, 16, 304, 454), + ), + FakeToolingGroup( + node = node, + box = FakeIntRect(16, 16, 304, 454), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints) + + val hint = hints.getValue(System.identityHashCode(node)) + assertEquals("LoginCard", hint.sourceName) + assertEquals("LoginPreview.kt", hint.sourceFile) + assertEquals(42, hint.sourceLine) + assertEquals("tooling-sibling-preorder", hint.sourceHintKind) + } + + @Test + fun `does not correlate sibling source call group when bounds disagree`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + children = + listOf( + FakeToolingGroup( + name = "UnrelatedHeader", + location = FakeSourceLocation("LoginPreview.kt", 12), + box = FakeIntRect(0, 0, 100, 40), + ), + FakeToolingGroup( + node = node, + box = FakeIntRect(16, 80, 304, 454), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints) + + assertEquals(null, hints[System.identityHashCode(node)]) + } + @Test fun `preview source fallback can enrich root layout node when tooling hints are absent`() { val outputFile = tempDir.resolve("layout-tree.json") @@ -257,7 +311,7 @@ class AndroidComposeRendererInRobolectricTest { val tree = outputFile.readText() assertTrue(tree.contains("\"sourceName\":\"LoginPreview\""), tree) assertTrue(tree.contains("\"sourceFile\":\"LoginPreview.kt\""), tree) - assertTrue(tree.contains("\"sourceLine\":1"), tree) + assertTrue(!tree.contains("\"sourceLine\""), tree) assertTrue(tree.contains("\"sourceHintKind\":\"preview-entrypoint-fallback\""), tree) } @@ -423,6 +477,48 @@ class AndroidComposeRendererInRobolectricTest { fun getName(): String = name } + private class FakeToolingGroup( + private val name: String? = null, + private val location: FakeSourceLocation? = null, + private val node: Any? = null, + private val box: FakeIntRect? = null, + private val children: List = emptyList(), + ) { + fun getName(): String? = name + + fun getLocation(): FakeSourceLocation? = location + + fun getNode(): Any? = node + + fun getBox(): FakeIntRect? = box + + fun getChildren(): List = children + } + + private class FakeSourceLocation( + private val sourceFile: String, + private val lineNumber: Int, + ) { + fun getSourceFile(): String = sourceFile + + fun getLineNumber(): Int = lineNumber + } + + private class FakeIntRect( + private val left: Int, + private val top: Int, + private val right: Int, + private val bottom: Int, + ) { + fun getLeft(): Int = left + + fun getTop(): Int = top + + fun getRight(): Int = right + + fun getBottom(): Int = bottom + } + private class FakeSemanticsNode { fun getChildren( includeReplacedSemantics: Boolean, From 93ebc35376cf883bcda39d1888362b3a1516bdca Mon Sep 17 00:00:00 2001 From: Shreyansh Lodha Date: Tue, 26 May 2026 00:11:30 +0530 Subject: [PATCH 3/5] Fix Compose tooling source hint reflection --- .../render/AndroidComposeRendererInRobolectric.kt | 9 ++++++++- .../AndroidComposeRendererInRobolectricTest.kt | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt index 62525bc..6ad9753 100644 --- a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt +++ b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt @@ -440,10 +440,17 @@ object AndroidComposeRendererInRobolectric { } } + internal fun accessibleNoArgMethod( + target: Any, + name: String, + ) = target.javaClass.methods + .firstOrNull { it.name == name && it.parameterTypes.isEmpty() } + ?.apply { isAccessible = true } + private fun method( target: Any, name: String, - ) = target.javaClass.methods.firstOrNull { it.name == name && it.parameterTypes.isEmpty() } + ) = accessibleNoArgMethod(target, name) private fun semanticsProperties(config: Any?): Map { val nonNullConfig = config ?: return emptyMap() diff --git a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt index a988947..1726739 100644 --- a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt +++ b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt @@ -231,6 +231,15 @@ class AndroidComposeRendererInRobolectricTest { assertTrue(enriched.componentHint.orEmpty().contains("FakeRowMeasurePolicy")) } + @Test + fun `accessible no arg reflection invokes public method on non-public implementation`() { + val record = FakePackagePrivateRecord(setOf("composition-data")) + + val store = AndroidComposeRendererInRobolectric.accessibleNoArgMethod(record, "getStore")?.invoke(record) + + assertEquals(setOf("composition-data"), store) + } + @Test fun `correlates sibling source call group to following node group by preorder and bounds`() { val node = Any() @@ -477,6 +486,12 @@ class AndroidComposeRendererInRobolectricTest { fun getName(): String = name } + private class FakePackagePrivateRecord( + private val store: Set, + ) { + fun getStore(): Set = store + } + private class FakeToolingGroup( private val name: String? = null, private val location: FakeSourceLocation? = null, From b03ce40246412c6bf9c231d9adfa985131052ae7 Mon Sep 17 00:00:00 2001 From: Shreyansh Lodha Date: Tue, 26 May 2026 00:24:56 +0530 Subject: [PATCH 4/5] Prefer app source hints for layout tree --- .../layout-tree-source-names-spike.md | 8 +- docs/snapshot-schema.md | 6 +- .../AndroidComposeRendererInRobolectric.kt | 126 +++++++++++++++--- ...AndroidComposeRendererInRobolectricTest.kt | 74 +++++++++- 4 files changed, 187 insertions(+), 27 deletions(-) diff --git a/docs/research/layout-tree-source-names-spike.md b/docs/research/layout-tree-source-names-spike.md index c928c3f..e6be6a0 100644 --- a/docs/research/layout-tree-source-names-spike.md +++ b/docs/research/layout-tree-source-names-spike.md @@ -134,17 +134,17 @@ Avoid replacing existing `componentHint`. Add nullable, best-effort fields under "sourceLine": 42, "sourceColumn": null, "sourcePackageHash": 123456, - "sourceNameConfidence": "tooling-ancestor-node-identity" + "sourceHintKind": "tooling-nearest-app-ancestor" } ``` Recommended semantics: -- `sourceName`: nullable composable/tooling group name nearest to the layout node. +- `sourceName`: nullable composable/tooling group name correlated to the layout node, preferring app/source-file groups over Compose runtime internals. - `sourceFile`, `sourceLine`, `sourceColumn`, `sourcePackageHash`: nullable source location parts from `SourceLocation`. -- `sourceNameConfidence`: optional nullable diagnostic enum/string such as `tooling-node-identity`, `tooling-ancestor-node-identity`, `tooling-bounds-preorder`, or `absent`. +- `sourceHintKind`: optional nullable diagnostic enum/string such as `tooling-node-identity`, `tooling-nearest-app-ancestor`, `tooling-sibling-preorder-app`, `tooling-useful-framework-ancestor`, `tooling-framework-ancestor`, or `preview-entrypoint-fallback`. -Do not require these fields for consumers. Do not fail snapshot extraction when tooling data is unavailable. +Do not require these fields for consumers. Tooling hints may still fall back to framework/internal groups when no app group can be correlated within ancestry/preorder/bounds constraints; CMP Android-target captures may remain preview-entrypoint-fallback-only when composition tooling data is unavailable. Do not fail snapshot extraction when tooling data is unavailable. ## Production recommendation diff --git a/docs/snapshot-schema.md b/docs/snapshot-schema.md index d21373e..91542d0 100644 --- a/docs/snapshot-schema.md +++ b/docs/snapshot-schema.md @@ -65,7 +65,7 @@ Version 2 adds preview parameter metadata, optional layout tree data, and option "sourceName": "LoginCard", "sourceFile": "LoginPreview.kt", "sourceLine": 24, - "sourceHintKind": "tooling-ancestor-node-identity", + "sourceHintKind": "tooling-nearest-app-ancestor", "modifierHint": "androidx.compose.ui.Modifier", "classHint": "androidx.compose.ui.node.LayoutNode", "semanticsId": "7", @@ -99,4 +99,6 @@ Future desktop and web renderers should use separate platform-specific viewport Fake renderer snapshots emit an empty `nodes` list because no real Compose semantics tree is available. Production renderer snapshots populate `nodes` from Compose semantics when available and may include `layoutTree` entries derived from the rendered Compose layout hierarchy. -`layoutTree` entries always keep `componentHint` as the implementation-level fallback. Production Android rendering may also add nullable best-effort source hints from Compose tooling data: `sourceName`, `sourceFile`, `sourceLine`, and `sourceHintKind`. These fields are optional, depend on Compose tooling/source information being available at render time, and are omitted if enrichment fails. `sourceLine` is emitted only when an actual positive source line is available; preview-entrypoint fallback hints may include `sourceName` and `sourceFile` with `sourceLine` omitted. +`layoutTree` entries always keep `componentHint` as the implementation-level fallback. Production Android rendering may also add nullable best-effort source hints from Compose tooling data: `sourceName`, `sourceFile`, `sourceLine`, and `sourceHintKind`. The correlation prefers app/preview source files over Compose runtime internals such as `ReusableComposeNode`, `Layout.kt`, `Composer.kt`, and `Composables.kt`; if no app group can be correlated within ancestry/preorder/bounds constraints, the hint may be a useful framework composable or a framework/internal fallback. Current hint kinds include `tooling-node-identity`, `tooling-nearest-app-ancestor`, `tooling-sibling-preorder-app`, `tooling-useful-framework-ancestor`, `tooling-framework-node-identity`, `tooling-framework-ancestor`, `tooling-sibling-preorder-framework`, and `preview-entrypoint-fallback`. + +These fields are optional, depend on Compose tooling/source information being available at render time, and are omitted if enrichment fails. `sourceLine` is emitted only when an actual positive source line is available; preview-entrypoint fallback hints may include `sourceName` and `sourceFile` with `sourceLine` omitted. Compose Multiplatform Android-target captures may currently emit only `preview-entrypoint-fallback` layout source hints when tooling composition data is unavailable for the rendered common source. diff --git a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt index 6ad9753..c86be30 100644 --- a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt +++ b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt @@ -18,6 +18,7 @@ import java.io.OutputStream import java.util.Locale import kotlin.math.roundToInt +@Suppress("LargeClass") object AndroidComposeRendererInRobolectric { fun render( className: String, @@ -245,10 +246,11 @@ object AndroidComposeRendererInRobolectric { } ?: error("Compose root view ${composeView.javaClass.name} does not expose getRoot().") val rootLayoutNode = getRoot.invoke(composeView) - val sourceHints = toolingRecord?.sourceHintsOrEmpty().orEmpty() + val fallbackSourceHint = previewSourceFallback?.toLayoutTreeSourceHint() + val sourceHints = toolingRecord?.sourceHintsOrEmpty(preferredAppSourceFile = fallbackSourceHint?.sourceFile).orEmpty() val fallbackHints = - if (sourceHints.isEmpty() && previewSourceFallback != null) { - mapOf(System.identityHashCode(rootLayoutNode) to previewSourceFallback.toLayoutTreeSourceHint()) + if (sourceHints.isEmpty() && fallbackSourceHint != null) { + mapOf(System.identityHashCode(rootLayoutNode) to fallbackSourceHint) } else { emptyMap() } @@ -580,20 +582,20 @@ object AndroidComposeRendererInRobolectric { } } - fun sourceHintsOrEmpty(): Map = - runCatching { sourceHints() }.getOrElse { throwable -> + fun sourceHintsOrEmpty(preferredAppSourceFile: String? = null): Map = + runCatching { sourceHints(preferredAppSourceFile) }.getOrElse { throwable -> warnSourceHintsDisabled("failed to read Compose tooling composition data", throwable) emptyMap() } - private fun sourceHints(): Map { + private fun sourceHints(preferredAppSourceFile: String?): Map { @Suppress("UNCHECKED_CAST") val store = method(record, "getStore")?.invoke(record) as? Iterable<*> ?: return emptyMap() val asTree = Class.forName("androidx.compose.ui.tooling.data.SlotTreeKt").getMethod("asTree", compositionDataClass()) return buildMap { store.filterNotNull().forEach { compositionData -> val rootGroup = asTree.invoke(null, compositionData) - collectGroupSourceHints(rootGroup, hints = this) + collectGroupSourceHints(rootGroup, hints = this, preferredAppSourceFile = preferredAppSourceFile) } } } @@ -626,40 +628,80 @@ object AndroidComposeRendererInRobolectric { } } + @Suppress("CyclomaticComplexMethod") internal fun collectGroupSourceHints( group: Any, hints: MutableMap, + preferredAppSourceFile: String? = null, ) { val entries = mutableListOf() collectToolingGroupEntries(group, parentPreorder = null, depth = 0, entries = entries) val sourceEntries = entries.filter { it.hint != null } entries.filter { it.node != null }.forEach { nodeEntry -> val node = nodeEntry.node ?: return@forEach - val directHint = nodeEntry.hint?.copy(sourceHintKind = "tooling-node-identity") - val ancestorHint = + val ancestorEntries = sourceEntries - .asSequence() .filter { sourceEntry -> sourceEntry.preorder in nodeEntry.ancestorPreorders } - .maxByOrNull { it.depth } - ?.hint - ?.copy(sourceHintKind = "tooling-ancestor-node-identity") - val siblingHint = + val siblingParentPreorders = + (nodeEntry.ancestorPreorders + nodeEntry.preorder) + .mapNotNull { preorder -> entries.getOrNull(preorder)?.parentPreorder } + .toSet() + val siblingEntries = sourceEntries - .asSequence() .filter { sourceEntry -> sourceEntry.preorder < nodeEntry.preorder && - sourceEntry.parentPreorder == nodeEntry.parentPreorder && + sourceEntry.parentPreorder in siblingParentPreorders && sourceEntry.box != null && nodeEntry.box != null && sourceEntry.box.contains(nodeEntry.box) - }.maxByOrNull { it.preorder } - ?.hint - ?.copy(sourceHintKind = "tooling-sibling-preorder") - val hint = directHint ?: ancestorHint ?: siblingHint + } + val hint = + nodeEntry.hint?.takeIf { it.isAppSourceHint(preferredAppSourceFile) }?.copy(sourceHintKind = "tooling-node-identity") + ?: ancestorEntries.nearestAppSourceHint("tooling-nearest-app-ancestor", preferredAppSourceFile) + ?: siblingEntries.nearestAppSourceHint("tooling-sibling-preorder-app", preferredAppSourceFile) + ?: nodeEntry.hint?.takeIf { it.isUsefulFrameworkSourceHint() }?.copy(sourceHintKind = "tooling-framework-node-identity") + ?: ancestorEntries.nearestUsefulFrameworkSourceHint("tooling-useful-framework-ancestor") + ?: siblingEntries.nearestUsefulFrameworkSourceHint("tooling-sibling-preorder-framework") + ?: nodeEntry.hint?.copy(sourceHintKind = "tooling-framework-node-identity") + ?: ancestorEntries.nearestSourceHint("tooling-framework-ancestor") + ?: siblingEntries.nearestSourceHint("tooling-sibling-preorder-framework") if (hint != null) hints[System.identityHashCode(node)] = hint } } + private fun List.nearestAppSourceHint( + sourceHintKind: String, + preferredAppSourceFile: String?, + ): LayoutTreeSourceHint? = + filter { it.hint?.isAppSourceHint(preferredAppSourceFile) == true } + .maxWithOrNull(compareBy { it.depth }.thenBy { it.preorder }) + ?.hint + ?.copy(sourceHintKind = sourceHintKind) + + private fun List.nearestUsefulFrameworkSourceHint(sourceHintKind: String): LayoutTreeSourceHint? = + filter { it.hint?.isUsefulFrameworkSourceHint() == true } + .maxWithOrNull(compareBy { it.depth }.thenBy { it.preorder }) + ?.hint + ?.copy(sourceHintKind = sourceHintKind) + + private fun List.nearestSourceHint(sourceHintKind: String): LayoutTreeSourceHint? = + maxWithOrNull(compareBy { it.depth }.thenBy { it.preorder }) + ?.hint + ?.copy(sourceHintKind = sourceHintKind) + + private fun LayoutTreeSourceHint.isAppSourceHint(preferredAppSourceFile: String?): Boolean { + val file = sourceFile.orEmpty() + val name = sourceName.orEmpty() + val knownFrameworkSourceFile = file in COMPOSE_INTERNAL_SOURCE_FILES || file in COMPOSE_PUBLIC_SOURCE_FILES + val internalSourceName = name in COMPOSE_INTERNAL_SOURCE_NAMES || name.startsWith("androidx.compose") + val matchesPreferredAppFile = preferredAppSourceFile != null && file == preferredAppSourceFile + return (matchesPreferredAppFile || (preferredAppSourceFile == null && (sourceFile != null || sourceName != null))) && + !knownFrameworkSourceFile && + !internalSourceName + } + + private fun LayoutTreeSourceHint.isUsefulFrameworkSourceHint(): Boolean = sourceName in USEFUL_COMPOSE_SOURCE_NAMES + private fun collectToolingGroupEntries( group: Any, parentPreorder: Int?, @@ -767,4 +809,48 @@ object AndroidComposeRendererInRobolectric { private const val UI_MODE_NIGHT_YES = 0x20 private const val DEFAULT_BACKGROUND_COLOR = -0x1 private val INT_RECT_PATTERN = Regex("-?\\d+") + private val COMPOSE_INTERNAL_SOURCE_FILES = + setOf( + "Layout.kt", + "Composer.kt", + "Composables.kt", + "Effects.kt", + "Updater.kt", + ) + private val COMPOSE_PUBLIC_SOURCE_FILES = + setOf( + "BasicText.kt", + "Box.kt", + "Button.kt", + "Card.kt", + "Column.kt", + "Row.kt", + "Spacer.kt", + "Surface.kt", + "Text.kt", + "ProvideContentColorTextStyle.kt", + ) + private val COMPOSE_INTERNAL_SOURCE_NAMES = + setOf( + "ReusableComposeNode", + "ComposeNode", + "ReusableNode", + "Layout", + "CompositionLocalProvider", + "startRestartGroup", + "startReplaceableGroup", + "startReusableGroup", + "Updater", + ) + private val USEFUL_COMPOSE_SOURCE_NAMES = + setOf( + "BasicText", + "Box", + "Button", + "Card", + "Column", + "Row", + "Spacer", + "Text", + ) } diff --git a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt index 1726739..9c34d29 100644 --- a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt +++ b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt @@ -266,7 +266,7 @@ class AndroidComposeRendererInRobolectricTest { assertEquals("LoginCard", hint.sourceName) assertEquals("LoginPreview.kt", hint.sourceFile) assertEquals(42, hint.sourceLine) - assertEquals("tooling-sibling-preorder", hint.sourceHintKind) + assertEquals("tooling-sibling-preorder-app", hint.sourceHintKind) } @Test @@ -294,6 +294,78 @@ class AndroidComposeRendererInRobolectricTest { assertEquals(null, hints[System.identityHashCode(node)]) } + @Test + fun `prefers app source ancestor over nearer compose runtime ancestor`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + name = "LoginPreview", + location = FakeSourceLocation("LoginPreview.kt", 18), + box = FakeIntRect(0, 0, 393, 852), + children = + listOf( + FakeToolingGroup( + name = "ReusableComposeNode", + location = FakeSourceLocation("Layout.kt", 83), + box = FakeIntRect(0, 0, 393, 852), + children = + listOf( + FakeToolingGroup( + node = node, + box = FakeIntRect(0, 0, 393, 852), + ), + ), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints) + + val hint = hints.getValue(System.identityHashCode(node)) + assertEquals("LoginPreview", hint.sourceName) + assertEquals("LoginPreview.kt", hint.sourceFile) + assertEquals(18, hint.sourceLine) + assertEquals("tooling-nearest-app-ancestor", hint.sourceHintKind) + } + + @Test + fun `prefers app sibling over nearer compose runtime ancestor when bounds contain node`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + children = + listOf( + FakeToolingGroup( + name = "LoginCard", + location = FakeSourceLocation("LoginPreview.kt", 42), + box = FakeIntRect(16, 16, 304, 454), + ), + FakeToolingGroup( + name = "ReusableComposeNode", + location = FakeSourceLocation("Layout.kt", 85), + box = FakeIntRect(16, 16, 304, 454), + children = + listOf( + FakeToolingGroup( + node = node, + box = FakeIntRect(16, 16, 304, 454), + ), + ), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints) + + val hint = hints.getValue(System.identityHashCode(node)) + assertEquals("LoginCard", hint.sourceName) + assertEquals("LoginPreview.kt", hint.sourceFile) + assertEquals(42, hint.sourceLine) + assertEquals("tooling-sibling-preorder-app", hint.sourceHintKind) + } + @Test fun `preview source fallback can enrich root layout node when tooling hints are absent`() { val outputFile = tempDir.resolve("layout-tree.json") From 414400635cc2b4f4db6ec6c85fbd6a21aa20dd9e Mon Sep 17 00:00:00 2001 From: Shreyansh Lodha Date: Tue, 26 May 2026 00:30:15 +0530 Subject: [PATCH 5/5] Fix layout source hint app detection --- .../AndroidComposeRendererInRobolectric.kt | 55 ++++++++++--- .../model/SnapshotSerializationTest.kt | 2 +- ...AndroidComposeRendererInRobolectricTest.kt | 81 ++++++++++++++++++- 3 files changed, 124 insertions(+), 14 deletions(-) diff --git a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt index c86be30..156d9bd 100644 --- a/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt +++ b/plugin/src/main/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectric.kt @@ -656,7 +656,7 @@ object AndroidComposeRendererInRobolectric { sourceEntry.box.contains(nodeEntry.box) } val hint = - nodeEntry.hint?.takeIf { it.isAppSourceHint(preferredAppSourceFile) }?.copy(sourceHintKind = "tooling-node-identity") + nodeEntry.hint?.takeIf { it.isAppSourceHint() }?.copy(sourceHintKind = "tooling-node-identity") ?: ancestorEntries.nearestAppSourceHint("tooling-nearest-app-ancestor", preferredAppSourceFile) ?: siblingEntries.nearestAppSourceHint("tooling-sibling-preorder-app", preferredAppSourceFile) ?: nodeEntry.hint?.takeIf { it.isUsefulFrameworkSourceHint() }?.copy(sourceHintKind = "tooling-framework-node-identity") @@ -673,9 +673,12 @@ object AndroidComposeRendererInRobolectric { sourceHintKind: String, preferredAppSourceFile: String?, ): LayoutTreeSourceHint? = - filter { it.hint?.isAppSourceHint(preferredAppSourceFile) == true } - .maxWithOrNull(compareBy { it.depth }.thenBy { it.preorder }) - ?.hint + filter { it.hint?.isAppSourceHint() == true } + .maxWithOrNull( + compareBy { it.depth } + .thenBy { entry -> entry.hint?.preferredAppSourceScore(preferredAppSourceFile) ?: 0 } + .thenBy { it.preorder }, + )?.hint ?.copy(sourceHintKind = sourceHintKind) private fun List.nearestUsefulFrameworkSourceHint(sourceHintKind: String): LayoutTreeSourceHint? = @@ -689,17 +692,31 @@ object AndroidComposeRendererInRobolectric { ?.hint ?.copy(sourceHintKind = sourceHintKind) - private fun LayoutTreeSourceHint.isAppSourceHint(preferredAppSourceFile: String?): Boolean { + private fun LayoutTreeSourceHint.isAppSourceHint(): Boolean { val file = sourceFile.orEmpty() val name = sourceName.orEmpty() - val knownFrameworkSourceFile = file in COMPOSE_INTERNAL_SOURCE_FILES || file in COMPOSE_PUBLIC_SOURCE_FILES - val internalSourceName = name in COMPOSE_INTERNAL_SOURCE_NAMES || name.startsWith("androidx.compose") - val matchesPreferredAppFile = preferredAppSourceFile != null && file == preferredAppSourceFile - return (matchesPreferredAppFile || (preferredAppSourceFile == null && (sourceFile != null || sourceName != null))) && - !knownFrameworkSourceFile && - !internalSourceName + return (sourceFile != null || sourceName != null) && + !file.isFrameworkSourceFile() && + !file.isGeneratedSourceFile() && + !name.isFrameworkSourceName() } + private fun LayoutTreeSourceHint.preferredAppSourceScore(preferredAppSourceFile: String?): Int = + if (preferredAppSourceFile != null && sourceFile == preferredAppSourceFile) 1 else 0 + + private fun String.isFrameworkSourceFile(): Boolean = this in COMPOSE_INTERNAL_SOURCE_FILES || this in COMPOSE_PUBLIC_SOURCE_FILES + + private fun String.isGeneratedSourceFile(): Boolean = + this in GENERATED_SOURCE_FILES || + endsWith(".generated.kt") || + endsWith(".Generated.kt") || + contains("/build/generated/") || + contains("\\build\\generated\\") + + private fun String.isFrameworkSourceName(): Boolean = + this in COMPOSE_INTERNAL_SOURCE_NAMES || + FRAMEWORK_SOURCE_NAME_PREFIXES.any { startsWith(it) } + private fun LayoutTreeSourceHint.isUsefulFrameworkSourceHint(): Boolean = sourceName in USEFUL_COMPOSE_SOURCE_NAMES private fun collectToolingGroupEntries( @@ -842,6 +859,22 @@ object AndroidComposeRendererInRobolectric { "startReusableGroup", "Updater", ) + private val GENERATED_SOURCE_FILES = + setOf( + "R.kt", + "BuildConfig.kt", + ) + private val FRAMEWORK_SOURCE_NAME_PREFIXES = + listOf( + "android.", + "androidx.", + "com.android.", + "java.", + "javax.", + "kotlin.", + "kotlinx.", + "org.jetbrains.compose.", + ) private val USEFUL_COMPOSE_SOURCE_NAMES = setOf( "BasicText", diff --git a/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt b/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt index 3f3761b..f15b4a7 100644 --- a/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt +++ b/plugin/src/test/kotlin/dev/staticvar/agentpreview/model/SnapshotSerializationTest.kt @@ -85,7 +85,7 @@ class SnapshotSerializationTest { sourceName = "LoginButton", sourceFile = "LoginPreview.kt", sourceLine = 42, - sourceHintKind = "tooling-ancestor-node-identity", + sourceHintKind = "tooling-nearest-app-ancestor", modifierHint = "androidx.compose.ui.Modifier", classHint = "androidx.compose.ui.node.LayoutNode", semanticsId = "7", diff --git a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt index 9c34d29..65cc639 100644 --- a/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt +++ b/plugin/src/test/kotlin/dev/staticvar/agentpreview/render/AndroidComposeRendererInRobolectricTest.kt @@ -217,7 +217,7 @@ class AndroidComposeRendererInRobolectricTest { sourceName = "LoginButton", sourceFile = "LoginPreview.kt", sourceLine = 42, - sourceHintKind = "tooling-ancestor-node-identity", + sourceHintKind = "tooling-nearest-app-ancestor", ), ) @@ -227,7 +227,7 @@ class AndroidComposeRendererInRobolectricTest { assertEquals("LoginButton", enriched.sourceName) assertEquals("LoginPreview.kt", enriched.sourceFile) assertEquals(42, enriched.sourceLine) - assertEquals("tooling-ancestor-node-identity", enriched.sourceHintKind) + assertEquals("tooling-nearest-app-ancestor", enriched.sourceHintKind) assertTrue(enriched.componentHint.orEmpty().contains("FakeRowMeasurePolicy")) } @@ -294,6 +294,83 @@ class AndroidComposeRendererInRobolectricTest { assertEquals(null, hints[System.identityHashCode(node)]) } + @Test + fun `app source in non preferred file beats nearer compose runtime ancestor`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + name = "LoginPreview", + location = FakeSourceLocation("LoginPreview.kt", 18), + box = FakeIntRect(0, 0, 393, 852), + children = + listOf( + FakeToolingGroup( + name = "OtherComposable", + location = FakeSourceLocation("OtherComposable.kt", 24), + box = FakeIntRect(0, 0, 393, 852), + children = + listOf( + FakeToolingGroup( + name = "ReusableComposeNode", + location = FakeSourceLocation("Layout.kt", 83), + box = FakeIntRect(0, 0, 393, 852), + children = + listOf( + FakeToolingGroup( + node = node, + box = FakeIntRect(0, 0, 393, 852), + ), + ), + ), + ), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints, preferredAppSourceFile = "LoginPreview.kt") + + val hint = hints.getValue(System.identityHashCode(node)) + assertEquals("OtherComposable", hint.sourceName) + assertEquals("OtherComposable.kt", hint.sourceFile) + assertEquals(24, hint.sourceLine) + assertEquals("tooling-nearest-app-ancestor", hint.sourceHintKind) + } + + @Test + fun `preferred preview source breaks ties among app siblings`() { + val node = Any() + val rootGroup = + FakeToolingGroup( + children = + listOf( + FakeToolingGroup( + name = "OtherComposable", + location = FakeSourceLocation("OtherComposable.kt", 24), + box = FakeIntRect(0, 0, 393, 852), + ), + FakeToolingGroup( + name = "LoginPreview", + location = FakeSourceLocation("LoginPreview.kt", 18), + box = FakeIntRect(0, 0, 393, 852), + ), + FakeToolingGroup( + node = node, + box = FakeIntRect(0, 0, 393, 852), + ), + ), + ) + val hints = mutableMapOf() + + AndroidComposeRendererInRobolectric.collectGroupSourceHints(rootGroup, hints, preferredAppSourceFile = "LoginPreview.kt") + + val hint = hints.getValue(System.identityHashCode(node)) + assertEquals("LoginPreview", hint.sourceName) + assertEquals("LoginPreview.kt", hint.sourceFile) + assertEquals(18, hint.sourceLine) + assertEquals("tooling-sibling-preorder-app", hint.sourceHintKind) + } + @Test fun `prefers app source ancestor over nearer compose runtime ancestor`() { val node = Any()