also looking at this
fix(native): preserve workspace sources and native overlay lifecycle
#384
20 files
+500
-227
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -74,3 +74,94 @@ fun main() { | ||
| 74 | 74 | } |
| 75 | 75 | `) |
| 76 | 76 | }, 180_000) |
| 77 | ||
| 78 | test('preserves modal viewport promotion and ordering across targeted reconciliation', async () => { | |
| 79 | const template = await Bun.file(new URL('../templates/MainActivityNative.kt.template', import.meta.url)).text() | |
| 80 | const promote = template.match(/ private fun promoteModalOverlays\(screen: Screen, controls: Collection<View>\) \{[\s\S]*?\n \}/)?.[0] | |
| 81 | const targeted = template.match(/ private fun applyTargetedUpdates\(screen: Screen, ids: List<String>\): Boolean \{[\s\S]*?\n \}/)?.[0] | |
| 82 | if (!promote || !targeted) throw new Error('Native modal reconciliation implementation is missing') | |
| 83 | await runKotlinFixture(` | |
| 84 | open class View { | |
| 85 | var parent: ViewGroup? = null | |
| 86 | fun bringToFront() { val group = parent ?: return; group.children.remove(this); group.children.add(this) } | |
| 87 | } | |
| 88 | open class ViewGroup : View() { | |
| 89 | val children = mutableListOf<View>() | |
| 90 | val childStyles = mutableMapOf<View, JSONObject>() | |
| 91 | fun removeView(view: View) { children.remove(view); childStyles.remove(view); view.parent = null } | |
| 92 | fun setChildStyle(view: View, style: JSONObject, density: Float) { childStyles[view] = style } | |
| 93 | fun addView(view: View) { view.parent?.removeView(view); children.add(view); view.parent = this } | |
| 94 | } | |
| 95 | class CraftNativeModalView : ViewGroup() { | |
| 96 | val content = ViewGroup().also(::addView) | |
| 97 | var viewportStyle = JSONObject("popup-frame") | |
| 98 | } | |
| 99 | class DisplayMetrics { val density = 1f } | |
| 100 | class Resources { val displayMetrics = DisplayMetrics() } | |
| 101 | val resources = Resources() | |
| 102 | class JSONObject(val id: String = "") { fun optJSONObject(name: String): JSONObject? = null } | |
| 103 | class MutationDocument(val parents: Map<String, String>) { | |
| 104 | fun parentId(id: String): String? = parents[id] | |
| 105 | fun node(id: String): JSONObject = JSONObject(id) | |
| 106 | } | |
| 107 | class Screen { | |
| 108 | val view = ViewGroup() | |
| 109 | val controls = linkedMapOf<String, View>() | |
| 110 | val nodeViews = mutableMapOf<String, View>() | |
| 111 | val nodePaths = mutableMapOf<String, String>() | |
| 112 | val protocolIds = mutableMapOf<View, String>() | |
| 113 | val lastLayoutBounds = mutableMapOf<View, String>() | |
| 114 | val mutations = MutationDocument(mapOf("parent" to "root", "nested" to "parent", "standalone" to "root")) | |
| 115 | var layoutEvents = 0 | |
| 116 | } | |
| 117 | fun drawnTarget(screen: Screen, id: String): String = id | |
| 118 | fun makeView(screen: Screen, document: JSONObject, path: String, controls: MutableMap<String, View>) { | |
| 119 | // Targeted parent reconciliation restores its logical child before promotion. | |
| 120 | if (document.id == "parent") (screen.nodeViews.getValue("parent") as CraftNativeModalView).content.addView(screen.nodeViews.getValue("nested")) | |
| 121 | } | |
| 122 | fun updateParentLayoutStyle(view: View, style: JSONObject): Boolean = true | |
| 123 | fun scheduleLayoutEvents(screen: Screen) { screen.layoutEvents++ } | |
| 124 | ${promote} | |
| 125 | ${targeted} | |
| 126 | fun main() { | |
| 127 | val screen = Screen() | |
| 128 | val root = ViewGroup() | |
| 129 | val parent = CraftNativeModalView() | |
| 130 | val nested = CraftNativeModalView() | |
| 131 | val standalone = CraftNativeModalView() | |
| 132 | screen.view.addView(root) | |
| 133 | root.addView(parent) | |
| 134 | parent.content.addView(nested) | |
| 135 | root.addView(standalone) | |
| 136 | for (modal in listOf(parent, nested, standalone)) screen.view.setChildStyle(modal, modal.viewportStyle, 1f) | |
| 137 | for ((id, view) in listOf("root" to root, "nested" to nested, "parent" to parent, "standalone" to standalone)) { | |
| 138 | screen.nodeViews[id] = view | |
| 139 | screen.nodePaths[id] = id | |
| 140 | screen.protocolIds[view] = id | |
| 141 | screen.controls[id] = view | |
| 142 | screen.controls["id:" + id] = view | |
| 143 | } | |
| 144 | fun verify() { | |
| 145 | check(parent.parent === screen.view && nested.parent === screen.view && standalone.parent === screen.view) { "An overlay escaped its viewport" } | |
| 146 | for (modal in listOf(parent, nested, standalone)) check(screen.view.childStyles[modal] === modal.viewportStyle) { "A promoted overlay lost its viewport layout style" } | |
| 147 | val children = screen.view.children | |
| 148 | check(children.indexOf(parent) < children.indexOf(nested)) { "The parent scrim covered its nested popup" } | |
| 149 | check(children.indexOf(nested) < children.indexOf(standalone)) { "Sibling overlay order changed" } | |
| 150 | } | |
| 151 | promoteModalOverlays(screen, screen.controls.values) | |
| 152 | verify() | |
| 153 | check(applyTargetedUpdates(screen, listOf("parent"))) | |
| 154 | verify() | |
| 155 | check(screen.layoutEvents == 1) | |
| 156 | check(applyTargetedUpdates(screen, listOf("standalone"))) | |
| 157 | verify() | |
| 158 | root.addView(parent) | |
| 159 | parent.content.addView(nested) | |
| 160 | root.addView(standalone) | |
| 161 | screen.protocolIds.clear() | |
| 162 | promoteModalOverlays(screen, screen.controls.values) | |
| 163 | verify() | |
| 164 | println("PASS modal viewport, nested scrim ordering, sibling ordering, targeted updates and legacy renders") | |
| 165 | } | |
| 166 | `) | |
| 167 | }, 180_000) | |
| @@ -2608,6 +2608,7 @@ private class CraftNativeIconView(context: Context) : TextView(context) { | ||
| 2608 | 2608 | } |
| 2609 | 2609 | |
| 2610 | 2610 | private class CraftNativeModalView(context: Context) : FrameLayout(context) { |
| 2611 | var viewportStyle = JSONObject() | |
| 2611 | 2612 | val content = CraftNativeFlexLayout(context) |
| 2612 | 2613 | private val blocker = View(context) |
| 2613 | 2614 | private var renderedVisible = false |
| @@ -2712,6 +2713,7 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) { | ||
| 2712 | 2713 | isClickable = true |
| 2713 | 2714 | blocksBackground = true |
| 2714 | 2715 | dismissOnOutsideTap = false |
| 2716 | viewportStyle = JSONObject() | |
| 2715 | 2717 | blocker.isEnabled = true |
| 2716 | 2718 | onRequestClose = null |
| 2717 | 2719 | } |
| @@ -4998,6 +5000,7 @@ class MainActivity : AppCompatActivity() { | ||
| 4998 | 5000 | if (!updateParentLayoutStyle(updated, document.optJSONObject("style") ?: JSONObject())) return false |
| 4999 | 5001 | } |
| 5000 | 5002 | if (applied.isNotEmpty()) { |
| 5003 | promoteModalOverlays(screen, screen.controls.values) | |
| 5001 | 5004 | scheduleLayoutEvents(screen) |
| 5002 | 5005 | } |
| 5003 | 5006 | return true |
| @@ -5098,13 +5101,31 @@ class MainActivity : AppCompatActivity() { | ||
| 5098 | 5101 | |
| 5099 | 5102 | /** Keep Modal nodes in the screen viewport even when declared in a clipped subtree. */ |
| 5100 | 5103 | private fun promoteModalOverlays(screen: Screen, controls: Collection<View>) { |
| 5101 | controls.filterIsInstance<CraftNativeModalView>().forEach { modal -> | |
| 5104 | val promoted = mutableSetOf<CraftNativeModalView>() | |
| 5105 | fun promote(modal: CraftNativeModalView) { | |
| 5106 | if (!promoted.add(modal)) return | |
| 5107 | // Native parents change after promotion; the committed tree keeps | |
| 5108 | // logical modal ancestry so a parent scrim stays below its popup. | |
| 5109 | var parentId = screen.protocolIds[modal]?.let { screen.mutations.parentId(it) } | |
| 5110 | while (parentId != null) { | |
| 5111 | (screen.nodeViews[parentId] as? CraftNativeModalView)?.let { promote(it) } | |
| 5112 | parentId = screen.mutations.parentId(parentId) | |
| 5113 | } | |
| 5114 | // Legacy full renders may not have protocol IDs yet. | |
| 5115 | var parent = modal.parent as? View | |
| 5116 | while (parent != null && parent !== screen.view) { | |
| 5117 | val nextParent = parent.parent as? View | |
| 5118 | if (parent is CraftNativeModalView) promote(parent) | |
| 5119 | parent = nextParent | |
| 5120 | } | |
| 5102 | 5121 | if (modal.parent !== screen.view) { |
| 5103 | 5122 | (modal.parent as? ViewGroup)?.removeView(modal) |
| 5104 | 5123 | screen.view.addView(modal) |
| 5124 | screen.view.setChildStyle(modal, modal.viewportStyle, resources.displayMetrics.density) | |
| 5105 | 5125 | } |
| 5106 | 5126 | modal.bringToFront() |
| 5107 | 5127 | } |
| 5128 | controls.filterIsInstance<CraftNativeModalView>().forEach { promote(it) } | |
| 5108 | 5129 | } |
| 5109 | 5130 | |
| 5110 | 5131 | private fun applyStatusBar(screen: Screen) { |
| @@ -5475,6 +5496,7 @@ class MainActivity : AppCompatActivity() { | ||
| 5475 | 5496 | // the screen root. Keep the authored layout style with that |
| 5476 | 5497 | // promoted child so non-modal absolute coordinates remain in |
| 5477 | 5498 | // the screen viewport rather than the old clipped parent. |
| 5499 | viewportStyle = style | |
| 5478 | 5500 | screen.view.setChildStyle(this, style, resources.displayMetrics.density) |
| 5479 | 5501 | content.configure(style, resources.displayMetrics.density) |
| 5480 | 5502 | reconcileChildren(screen, content, children, identity, next, style, previousControls) |