@thelacanians/vue-native-cli 0.4.14 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,20 @@ class NativeBridge(private val context: Context) {
23
23
  "create", "createText", "appendChild", "insertBefore", "removeChild",
24
24
  "setRootView", "setText", "setElementText"
25
25
  )
26
+
27
+ /** Style properties that affect layout and require a layout pass when changed. */
28
+ private val layoutAffectingStyles = setOf(
29
+ "width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight",
30
+ "flex", "flexGrow", "flexShrink", "flexBasis", "flexDirection",
31
+ "flexWrap", "alignItems", "alignSelf", "alignContent", "justifyContent",
32
+ "padding", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
33
+ "paddingHorizontal", "paddingVertical", "paddingStart", "paddingEnd",
34
+ "margin", "marginTop", "marginRight", "marginBottom", "marginLeft",
35
+ "marginHorizontal", "marginVertical", "marginStart", "marginEnd",
36
+ "gap", "rowGap", "columnGap",
37
+ "position", "top", "right", "bottom", "left", "start", "end",
38
+ "aspectRatio", "display", "overflow", "direction",
39
+ )
26
40
  }
27
41
 
28
42
  private val mainHandler = Handler(Looper.getMainLooper())
@@ -81,20 +95,34 @@ class NativeBridge(private val context: Context) {
81
95
  }
82
96
 
83
97
  mainHandler.post {
84
- var hasMutations = false
98
+ var needsLayout = false
85
99
  for (op in ops) {
86
100
  try {
87
101
  val opName = op.optString("op")
88
- if (!hasMutations && opName in treeMutationOps) {
89
- hasMutations = true
102
+ if (!needsLayout && opName in treeMutationOps) {
103
+ needsLayout = true
104
+ }
105
+ // Check if updateStyle changes any layout-affecting property
106
+ if (!needsLayout && opName == "updateStyle") {
107
+ val args = op.optJSONArray("args")
108
+ val styleObj = args?.optJSONObject(1)
109
+ if (styleObj != null) {
110
+ val keys = styleObj.keys()
111
+ while (keys.hasNext()) {
112
+ if (keys.next() in layoutAffectingStyles) {
113
+ needsLayout = true
114
+ break
115
+ }
116
+ }
117
+ }
90
118
  }
91
119
  handleOperation(op)
92
120
  } catch (e: Exception) {
93
121
  Log.e(TAG, "Error handling op '${op.optString("op")}': ${e.message}")
94
122
  }
95
123
  }
96
- // Only trigger layout when the batch mutated the view tree
97
- if (hasMutations) {
124
+ // Trigger layout when tree was mutated or layout-affecting styles changed
125
+ if (needsLayout) {
98
126
  triggerLayout()
99
127
  }
100
128
  }
@@ -380,6 +408,11 @@ class NativeBridge(private val context: Context) {
380
408
 
381
409
  /** Clear all registries. Called during hot reload after JS teardown. Must run on main thread. */
382
410
  fun clearAllRegistries() {
411
+ // Destroy all views via their factories before clearing maps
412
+ for ((_, view) in nodeViews) {
413
+ val factory = componentRegistry.factoryForView(view)
414
+ factory?.destroyView(view)
415
+ }
383
416
  nodeViews.clear()
384
417
  nodeTypes.clear()
385
418
  eventHandlers.clear()
@@ -98,9 +98,16 @@ class VListFactory : NativeComponentFactory {
98
98
  cumulativeX += dx
99
99
  cumulativeY += dy
100
100
 
101
- // Dispatch scroll event with cumulative position
101
+ // Dispatch scroll event with cumulative position and dimensions
102
102
  scrollHandlers[recyclerView]?.invoke(
103
- mapOf("x" to cumulativeX, "y" to cumulativeY)
103
+ mapOf(
104
+ "x" to cumulativeX,
105
+ "y" to cumulativeY,
106
+ "contentWidth" to (recyclerView.computeHorizontalScrollRange()),
107
+ "contentHeight" to (recyclerView.computeVerticalScrollRange()),
108
+ "layoutWidth" to recyclerView.width,
109
+ "layoutHeight" to recyclerView.height,
110
+ )
104
111
  )
105
112
 
106
113
  // endReached detection (threshold = 20% from bottom)
@@ -167,20 +174,33 @@ class VListFactory : NativeComponentFactory {
167
174
  }
168
175
 
169
176
  class VListAdapter(private val items: List<View>) : RecyclerView.Adapter<VListAdapter.VH>() {
170
- class VH(val view: View) : RecyclerView.ViewHolder(view)
177
+ class VH(itemView: View) : RecyclerView.ViewHolder(itemView)
171
178
 
172
179
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
173
- val v = items.getOrNull(viewType) ?: android.widget.FrameLayout(parent.context)
174
- // Remove from any existing parent
175
- (v.parent as? ViewGroup)?.removeView(v)
176
- v.layoutParams = RecyclerView.LayoutParams(
177
- RecyclerView.LayoutParams.MATCH_PARENT,
178
- RecyclerView.LayoutParams.WRAP_CONTENT
179
- )
180
- return VH(v)
180
+ val container = android.widget.FrameLayout(parent.context).apply {
181
+ layoutParams = RecyclerView.LayoutParams(
182
+ RecyclerView.LayoutParams.MATCH_PARENT,
183
+ RecyclerView.LayoutParams.WRAP_CONTENT
184
+ )
185
+ }
186
+ return VH(container)
187
+ }
188
+
189
+ override fun onBindViewHolder(holder: VH, position: Int) {
190
+ val container = holder.itemView as android.widget.FrameLayout
191
+ // Remove the previous item view from this recycled container
192
+ container.removeAllViews()
193
+ val itemView = items.getOrNull(position) ?: return
194
+ // Remove from any existing parent (previous container or direct parent)
195
+ (itemView.parent as? ViewGroup)?.removeView(itemView)
196
+ container.addView(itemView, ViewGroup.LayoutParams(
197
+ ViewGroup.LayoutParams.MATCH_PARENT,
198
+ ViewGroup.LayoutParams.WRAP_CONTENT
199
+ ))
181
200
  }
182
201
 
183
- override fun onBindViewHolder(holder: VH, position: Int) {}
184
202
  override fun getItemCount(): Int = items.size
185
- override fun getItemViewType(position: Int): Int = position
203
+
204
+ // Use a single view type so RecyclerView can reuse all ViewHolders
205
+ override fun getItemViewType(position: Int): Int = 0
186
206
  }
@@ -64,6 +64,9 @@ class VScrollViewFactory : NativeComponentFactory {
64
64
  }
65
65
  }
66
66
 
67
+ private val scrollThrottles = mutableMapOf<SwipeRefreshLayout, EventThrottle>()
68
+ private val scrollListeners = mutableMapOf<SwipeRefreshLayout, android.view.ViewTreeObserver.OnScrollChangedListener>()
69
+
67
70
  override fun addEventListener(view: View, event: String, handler: (Any?) -> Unit) {
68
71
  val srf = view as? SwipeRefreshLayout ?: return
69
72
  val scroll = states[srf]?.scrollView
@@ -73,20 +76,38 @@ class VScrollViewFactory : NativeComponentFactory {
73
76
  srf.setOnRefreshListener { handler(null) }
74
77
  }
75
78
  "scroll" -> {
76
- scroll?.viewTreeObserver?.addOnScrollChangedListener {
77
- handler(mapOf(
78
- "contentOffset" to mapOf("x" to scroll.scrollX, "y" to scroll.scrollY)
79
+ val throttle = EventThrottle(intervalMs = 16L, handler = handler)
80
+ scrollThrottles[srf] = throttle
81
+ val listener = android.view.ViewTreeObserver.OnScrollChangedListener {
82
+ val child = if (scroll != null && scroll.childCount > 0) scroll.getChildAt(0) else null
83
+ throttle.fire(mapOf(
84
+ "x" to (scroll?.scrollX ?: 0),
85
+ "y" to (scroll?.scrollY ?: 0),
86
+ "contentWidth" to (child?.width ?: scroll?.width ?: 0),
87
+ "contentHeight" to (child?.height ?: scroll?.height ?: 0),
88
+ "layoutWidth" to (scroll?.width ?: 0),
89
+ "layoutHeight" to (scroll?.height ?: 0),
79
90
  ))
80
91
  }
92
+ scrollListeners[srf] = listener
93
+ scroll?.viewTreeObserver?.addOnScrollChangedListener(listener)
81
94
  }
82
95
  }
83
96
  }
84
97
 
85
98
  override fun removeEventListener(view: View, event: String) {
86
99
  val srf = view as? SwipeRefreshLayout ?: return
87
- if (event == "refresh") {
88
- srf.setOnRefreshListener(null)
89
- srf.isEnabled = false
100
+ when (event) {
101
+ "refresh" -> {
102
+ srf.setOnRefreshListener(null)
103
+ srf.isEnabled = false
104
+ }
105
+ "scroll" -> {
106
+ scrollListeners.remove(srf)?.let { listener ->
107
+ states[srf]?.scrollView?.viewTreeObserver?.removeOnScrollChangedListener(listener)
108
+ }
109
+ scrollThrottles.remove(srf)?.cancel()
110
+ }
90
111
  }
91
112
  }
92
113
 
@@ -48,18 +48,24 @@ class VSliderFactory : NativeComponentFactory {
48
48
  }
49
49
  }
50
50
 
51
+ private val throttles = mutableMapOf<SeekBar, EventThrottle>()
52
+
51
53
  override fun addEventListener(view: View, event: String, handler: (Any?) -> Unit) {
52
54
  val sb = view as? SeekBar ?: return
53
55
  when (event) {
54
56
  "change", "valueChange" -> {
55
57
  changeHandlers[sb] = handler
58
+ val throttle = EventThrottle(intervalMs = 16L, handler = handler)
59
+ throttles[sb] = throttle
56
60
  sb.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
57
61
  override fun onProgressChanged(s: SeekBar, progress: Int, fromUser: Boolean) {
58
- if (fromUser) changeHandlers[s]?.invoke(mapOf("value" to progress.toFloat()))
62
+ if (fromUser) throttle.fire(mapOf("value" to progress.toFloat() / s.max.toFloat()))
59
63
  }
60
64
  override fun onStartTrackingTouch(s: SeekBar) {}
61
65
  override fun onStopTrackingTouch(s: SeekBar) {
62
- changeHandlers[s]?.invoke(mapOf("value" to s.progress.toFloat()))
66
+ // Always deliver the final value immediately on release
67
+ throttle.cancel()
68
+ changeHandlers[s]?.invoke(mapOf("value" to s.progress.toFloat() / s.max.toFloat()))
63
69
  }
64
70
  })
65
71
  }
@@ -69,6 +75,7 @@ class VSliderFactory : NativeComponentFactory {
69
75
  override fun removeEventListener(view: View, event: String) {
70
76
  val sb = view as? SeekBar ?: return
71
77
  changeHandlers.remove(sb)
78
+ throttles.remove(sb)?.cancel()
72
79
  sb.setOnSeekBarChangeListener(null)
73
80
  }
74
81
  }
@@ -0,0 +1,57 @@
1
+ package com.vuenative.core
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.os.SystemClock
6
+
7
+ /**
8
+ * Throttles high-frequency event handlers to avoid flooding the JS bridge.
9
+ *
10
+ * When a high-frequency event (scroll, slider drag) fires many times per frame,
11
+ * each invocation becomes a bridge round-trip. This utility ensures at most one
12
+ * call per [intervalMs] milliseconds, with a trailing call to deliver the latest value.
13
+ *
14
+ * Default interval: 16ms (~60 FPS).
15
+ */
16
+ class EventThrottle(
17
+ private val intervalMs: Long = 16L,
18
+ private val handler: (Any?) -> Unit
19
+ ) {
20
+ private var lastFireTime: Long = 0
21
+ private var pendingTrailing = false
22
+ private var latestPayload: Any? = null
23
+ private val mainHandler = Handler(Looper.getMainLooper())
24
+
25
+ private val trailingRunnable = Runnable {
26
+ lastFireTime = SystemClock.uptimeMillis()
27
+ pendingTrailing = false
28
+ handler(latestPayload)
29
+ }
30
+
31
+ /**
32
+ * Call this from the native event callback instead of the original handler.
33
+ * Fires immediately if enough time has elapsed, otherwise schedules a trailing call.
34
+ */
35
+ fun fire(payload: Any?) {
36
+ val now = SystemClock.uptimeMillis()
37
+ val elapsed = now - lastFireTime
38
+ latestPayload = payload
39
+
40
+ if (elapsed >= intervalMs) {
41
+ lastFireTime = now
42
+ pendingTrailing = false
43
+ mainHandler.removeCallbacks(trailingRunnable)
44
+ handler(payload)
45
+ } else if (!pendingTrailing) {
46
+ pendingTrailing = true
47
+ mainHandler.postDelayed(trailingRunnable, intervalMs - elapsed)
48
+ }
49
+ // If trailing is already pending, latestPayload is updated above
50
+ }
51
+
52
+ /** Cancel any pending trailing call. */
53
+ fun cancel() {
54
+ mainHandler.removeCallbacks(trailingRunnable)
55
+ pendingTrailing = false
56
+ }
57
+ }
@@ -0,0 +1,79 @@
1
+ #if canImport(UIKit)
2
+ import Foundation
3
+
4
+ /// Throttles high-frequency event handlers to avoid flooding the JS bridge.
5
+ ///
6
+ /// When a high-frequency event (scroll, slider drag, text input) fires many
7
+ /// times per frame, each invocation becomes a bridge round-trip. This utility
8
+ /// ensures at most one call per `interval` seconds, with a trailing call
9
+ /// to deliver the latest value.
10
+ ///
11
+ /// Default interval: 16ms (~60 FPS). Callers can customize via `interval`.
12
+ final class EventThrottle {
13
+
14
+ /// Minimum time between handler invocations (seconds).
15
+ let interval: TimeInterval
16
+
17
+ /// The throttled handler to call.
18
+ private let handler: (Any?) -> Void
19
+
20
+ /// Timestamp of the last invocation.
21
+ private var lastFireTime: CFTimeInterval = 0
22
+
23
+ /// Whether a trailing call is pending.
24
+ private var pendingTrailing = false
25
+
26
+ /// The most recent payload, used for the trailing call.
27
+ private var latestPayload: Any?
28
+
29
+ /// Timer for delivering the trailing call.
30
+ private var trailingTimer: DispatchSourceTimer?
31
+
32
+ /// Create a throttled event handler.
33
+ /// - Parameters:
34
+ /// - interval: Minimum seconds between invocations. Default 0.016 (~60fps).
35
+ /// - handler: The closure to invoke with the event payload.
36
+ init(interval: TimeInterval = 0.016, handler: @escaping (Any?) -> Void) {
37
+ self.interval = interval
38
+ self.handler = handler
39
+ }
40
+
41
+ deinit {
42
+ trailingTimer?.cancel()
43
+ }
44
+
45
+ /// Call this from the native event callback instead of the original handler.
46
+ /// Fires immediately if enough time has elapsed, otherwise schedules a trailing call.
47
+ func fire(_ payload: Any?) {
48
+ let now = CACurrentMediaTime()
49
+ let elapsed = now - lastFireTime
50
+
51
+ latestPayload = payload
52
+
53
+ if elapsed >= interval {
54
+ // Enough time has passed — fire immediately
55
+ lastFireTime = now
56
+ pendingTrailing = false
57
+ trailingTimer?.cancel()
58
+ trailingTimer = nil
59
+ handler(payload)
60
+ } else if !pendingTrailing {
61
+ // Schedule a trailing call for the remaining time
62
+ pendingTrailing = true
63
+ let remaining = interval - elapsed
64
+ let timer = DispatchSource.makeTimerSource(queue: .main)
65
+ timer.schedule(deadline: .now() + remaining)
66
+ timer.setEventHandler { [weak self] in
67
+ guard let self = self else { return }
68
+ self.lastFireTime = CACurrentMediaTime()
69
+ self.pendingTrailing = false
70
+ self.trailingTimer = nil
71
+ self.handler(self.latestPayload)
72
+ }
73
+ trailingTimer = timer
74
+ timer.resume()
75
+ }
76
+ // If a trailing call is already pending, just update latestPayload (done above)
77
+ }
78
+ }
79
+ #endif
@@ -69,6 +69,59 @@ public final class NativeBridge {
69
69
 
70
70
  private static var traitObserverKey: UInt8 = 99
71
71
 
72
+ // MARK: - Bridge Function Registration
73
+
74
+ /// Register `__VN_flushOperations`, `__VN_teardown`, `__VN_log`, and `__VN_handleError`
75
+ /// on the given JSContext. Called from both `initialize()` and `reloadWithBundle()`.
76
+ private func registerBridgeFunctions(on context: JSContext) {
77
+ let flushOps: @convention(block) (JSValue) -> Void = { [weak self] opsValue in
78
+ guard let self = self else { return }
79
+ guard let jsonString = opsValue.toString(), !jsonString.isEmpty else {
80
+ NSLog("[VueNative Bridge] Warning: Empty operations batch")
81
+ return
82
+ }
83
+
84
+ guard let data = jsonString.data(using: .utf8),
85
+ let operations = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
86
+ NSLog("[VueNative Bridge] Error: Failed to parse operations JSON")
87
+ return
88
+ }
89
+
90
+ DispatchQueue.main.async { [weak self] in
91
+ self?.processOperations(operations)
92
+ }
93
+ }
94
+ context.setObject(flushOps, forKeyedSubscript: "__VN_flushOperations" as NSString)
95
+
96
+ let teardown: @convention(block) () -> Void = { [weak self] in
97
+ NSLog("[VueNative] Teardown called from JS")
98
+ _ = self
99
+ }
100
+ context.setObject(teardown, forKeyedSubscript: "__VN_teardown" as NSString)
101
+
102
+ let log: @convention(block) (JSValue) -> Void = { message in
103
+ NSLog("[VueNative JS] \(message.toString() ?? "")")
104
+ }
105
+ context.setObject(log, forKeyedSubscript: "__VN_log" as NSString)
106
+
107
+ let handleError: @convention(block) (JSValue) -> Void = { errorInfoValue in
108
+ let jsonString = errorInfoValue.toString() ?? "{}"
109
+ NSLog("[VueNative Error] %@", jsonString)
110
+
111
+ if let data = jsonString.data(using: .utf8),
112
+ let info = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
113
+ let message = info["message"] as? String ?? "Unknown error"
114
+ let stack = info["stack"] as? String ?? ""
115
+ let componentName = info["componentName"] as? String ?? "unknown"
116
+ NSLog("[VueNative Error] Component: %@, Message: %@", componentName, message)
117
+ if !stack.isEmpty {
118
+ NSLog("[VueNative Error] Stack: %@", stack)
119
+ }
120
+ }
121
+ }
122
+ context.setObject(handleError, forKeyedSubscript: "__VN_handleError" as NSString)
123
+ }
124
+
72
125
  // MARK: - Setup
73
126
 
74
127
  /// Initialize the bridge. Must be called after JSRuntime.initialize().
@@ -83,61 +136,7 @@ public final class NativeBridge {
83
136
  runtime.jsQueue.async { [weak self] in
84
137
  guard let self = self, let context = runtime.context else { return }
85
138
 
86
- let flushOps: @convention(block) (JSValue) -> Void = { [weak self] opsValue in
87
- guard let self = self else { return }
88
- guard let jsonString = opsValue.toString(), !jsonString.isEmpty else {
89
- NSLog("[VueNative Bridge] Warning: Empty operations batch")
90
- return
91
- }
92
-
93
- // Parse JSON on the JS queue (avoid main thread work)
94
- guard let data = jsonString.data(using: .utf8),
95
- let operations = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
96
- NSLog("[VueNative Bridge] Error: Failed to parse operations JSON")
97
- return
98
- }
99
-
100
- // Process operations on the main thread
101
- DispatchQueue.main.async { [weak self] in
102
- self?.processOperations(operations)
103
- }
104
- }
105
- context.setObject(flushOps, forKeyedSubscript: "__VN_flushOperations" as NSString)
106
-
107
- // Register __VN_teardown for graceful JS-side shutdown on hot reload
108
- let teardown: @convention(block) () -> Void = { [weak self] in
109
- // Graceful shutdown: bridge will be re-initialized on reload
110
- NSLog("[VueNative] Teardown called from JS")
111
- _ = self
112
- }
113
- context.setObject(teardown, forKeyedSubscript: "__VN_teardown" as NSString)
114
-
115
- // Register __VN_log for debug logging from JS
116
- let log: @convention(block) (JSValue) -> Void = { message in
117
- NSLog("[VueNative JS] \(message.toString() ?? "")")
118
- }
119
- context.setObject(log, forKeyedSubscript: "__VN_log" as NSString)
120
-
121
- // Register __VN_handleError for JS error reporting
122
- // Called from JS with a JSON-encoded string containing error info
123
- // (message, stack, componentName).
124
- let handleError: @convention(block) (JSValue) -> Void = { errorInfoValue in
125
- let jsonString = errorInfoValue.toString() ?? "{}"
126
- NSLog("[VueNative Error] %@", jsonString)
127
-
128
- // Try to extract structured fields for clearer logging
129
- if let data = jsonString.data(using: .utf8),
130
- let info = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
131
- let message = info["message"] as? String ?? "Unknown error"
132
- let stack = info["stack"] as? String ?? ""
133
- let componentName = info["componentName"] as? String ?? "unknown"
134
- NSLog("[VueNative Error] Component: %@, Message: %@", componentName, message)
135
- if !stack.isEmpty {
136
- NSLog("[VueNative Error] Stack: %@", stack)
137
- }
138
- }
139
- }
140
- context.setObject(handleError, forKeyedSubscript: "__VN_handleError" as NSString)
139
+ self.registerBridgeFunctions(on: context)
141
140
  }
142
141
 
143
142
  // Register all native modules synchronously so they are available
@@ -153,19 +152,44 @@ public final class NativeBridge {
153
152
  "setRootView", "setText", "setElementText"
154
153
  ]
155
154
 
155
+ /// Style properties that affect Yoga layout and require a layout pass when changed.
156
+ /// When a batch contains only updateStyle ops for non-layout properties (e.g.
157
+ /// backgroundColor, opacity), we skip the expensive layout recalculation.
158
+ private static let layoutAffectingStyles: Set<String> = [
159
+ // Dimensions
160
+ "width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight",
161
+ // Flex
162
+ "flex", "flexGrow", "flexShrink", "flexBasis", "flexDirection",
163
+ "flexWrap", "alignItems", "alignSelf", "alignContent", "justifyContent",
164
+ // Spacing
165
+ "padding", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
166
+ "paddingHorizontal", "paddingVertical", "paddingStart", "paddingEnd",
167
+ "margin", "marginTop", "marginRight", "marginBottom", "marginLeft",
168
+ "marginHorizontal", "marginVertical", "marginStart", "marginEnd",
169
+ // Gap
170
+ "gap", "rowGap", "columnGap",
171
+ // Position
172
+ "position", "top", "right", "bottom", "left", "start", "end",
173
+ // Other layout
174
+ "aspectRatio", "display", "overflow", "direction",
175
+ ]
176
+
156
177
  /// Process a batch of operations on the main thread.
157
178
  /// Each operation has an "op" key and "args" array.
158
- /// Only triggers a Yoga layout pass when the batch contains tree mutations
159
- /// (create, appendChild, insertBefore, removeChild, etc.). Batches that
160
- /// only update props/styles/events skip the expensive layout recalculation.
179
+ /// Triggers a Yoga layout pass when the batch contains tree mutations
180
+ /// (create, appendChild, insertBefore, removeChild, etc.) OR style changes
181
+ /// that affect layout (width, height, padding, margin, flex, etc.).
182
+ /// Batches that only update visual styles/events skip the expensive layout.
161
183
  ///
162
184
  /// Access: internal (not private) so that `@testable import` can exercise
163
185
  /// operation handling without going through JSContext.
164
186
  func processOperations(_ operations: [[String: Any]]) {
165
187
  dispatchPrecondition(condition: .onQueue(.main))
188
+ #if DEBUG
166
189
  NSLog("[VueNative Bridge] Processing %d operations", operations.count)
190
+ #endif
167
191
 
168
- var hasMutations = false
192
+ var needsLayout = false
169
193
 
170
194
  for operation in operations {
171
195
  guard let op = operation["op"] as? String,
@@ -174,8 +198,20 @@ public final class NativeBridge {
174
198
  continue
175
199
  }
176
200
 
177
- if !hasMutations && NativeBridge.treeMutationOps.contains(op) {
178
- hasMutations = true
201
+ if !needsLayout && NativeBridge.treeMutationOps.contains(op) {
202
+ needsLayout = true
203
+ }
204
+
205
+ // Check if updateStyle changes any layout-affecting property
206
+ if !needsLayout && op == "updateStyle",
207
+ args.count >= 2,
208
+ let styles = args[1] as? [String: Any] {
209
+ for key in styles.keys {
210
+ if NativeBridge.layoutAffectingStyles.contains(key) {
211
+ needsLayout = true
212
+ break
213
+ }
214
+ }
179
215
  }
180
216
 
181
217
  switch op {
@@ -212,8 +248,8 @@ public final class NativeBridge {
212
248
  }
213
249
  }
214
250
 
215
- // Only trigger layout recalculation when the batch mutated the view tree
216
- if hasMutations {
251
+ // Trigger layout when tree was mutated or layout-affecting styles changed
252
+ if needsLayout {
217
253
  triggerLayout()
218
254
  }
219
255
  }
@@ -388,8 +424,12 @@ public final class NativeBridge {
388
424
  if let factory = ComponentRegistry.factory(for: parentView) {
389
425
  factory.insertChild(childView, into: container, before: beforeView)
390
426
  } else if let index = container.subviews.firstIndex(of: beforeView) {
391
- container.flex.addItem(childView)
392
427
  container.insertSubview(childView, at: index)
428
+ // Rebuild Yoga children to match UIView subview order
429
+ container.flex.removeAllChildren()
430
+ for subview in container.subviews {
431
+ container.flex.addItem(subview)
432
+ }
393
433
  } else {
394
434
  container.flex.addItem(childView)
395
435
  }
@@ -781,55 +821,9 @@ public final class NativeBridge {
781
821
  return
782
822
  }
783
823
 
784
- // Re-register __VN_flushOperations, __VN_teardown, __VN_log on new context
824
+ // Re-register bridge functions on new context
785
825
  guard let context = self.runtime.context else { return }
786
-
787
- let flushOps: @convention(block) (JSValue) -> Void = { [weak self] opsValue in
788
- guard let self = self else { return }
789
- guard let jsonString = opsValue.toString(), !jsonString.isEmpty else {
790
- NSLog("[VueNative Bridge] Warning: Empty operations batch")
791
- return
792
- }
793
-
794
- guard let data = jsonString.data(using: .utf8),
795
- let operations = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
796
- NSLog("[VueNative Bridge] Error: Failed to parse operations JSON")
797
- return
798
- }
799
-
800
- DispatchQueue.main.async { [weak self] in
801
- self?.processOperations(operations)
802
- }
803
- }
804
- context.setObject(flushOps, forKeyedSubscript: "__VN_flushOperations" as NSString)
805
-
806
- let teardown: @convention(block) () -> Void = { [weak self] in
807
- NSLog("[VueNative] Teardown called from JS")
808
- _ = self
809
- }
810
- context.setObject(teardown, forKeyedSubscript: "__VN_teardown" as NSString)
811
-
812
- let log: @convention(block) (JSValue) -> Void = { message in
813
- NSLog("[VueNative JS] \(message.toString() ?? "")")
814
- }
815
- context.setObject(log, forKeyedSubscript: "__VN_log" as NSString)
816
-
817
- let handleError: @convention(block) (JSValue) -> Void = { errorInfoValue in
818
- let jsonString = errorInfoValue.toString() ?? "{}"
819
- NSLog("[VueNative Error] %@", jsonString)
820
-
821
- if let data = jsonString.data(using: .utf8),
822
- let info = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
823
- let message = info["message"] as? String ?? "Unknown error"
824
- let stack = info["stack"] as? String ?? ""
825
- let componentName = info["componentName"] as? String ?? "unknown"
826
- NSLog("[VueNative Error] Component: %@, Message: %@", componentName, message)
827
- if !stack.isEmpty {
828
- NSLog("[VueNative Error] Stack: %@", stack)
829
- }
830
- }
831
- }
832
- context.setObject(handleError, forKeyedSubscript: "__VN_handleError" as NSString)
826
+ self.registerBridgeFunctions(on: context)
833
827
 
834
828
  NSLog("[VueNative Bridge] reloadWithBundle: bridge re-registered on new context")
835
829
  }