constructViewTest: create ReactHostImpl directly (bridgeless)#103
constructViewTest: create ReactHostImpl directly (bridgeless)#103EmilioBejasa wants to merge 8 commits intomainfrom
Conversation
…tion Removes dependency on MainApplication by constructing ReactHostImpl directly in the test with dev support and packager access disabled. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace ViewHelpers.setupView with WindowManager.TYPE_APPLICATION_OVERLAY so Fabric's Choreographer can fire mutations against a real Window - Add reactHost.onHostResume(null) so Fabric commits its render tree (without RESUMED lifecycle state, all mutations are silently dropped) - Wait for ReactMarkerConstants.CONTENT_APPEARED before snapping screenshot - Set software layer type so Screenshot.snap() can capture via draw(canvas) - Grant SYSTEM_ALERT_WINDOW permission required for overlay window type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents checkered (transparent) background in captured screenshots. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tdrhq
left a comment
There was a problem hiding this comment.
this seems like it might work, with some tweaks.
Remember this code that I showed you earlier: https://github.com/screenshotbot/screenshot-tests-for-android/blob/main/compose/src/main/java/com/facebook/testing/screenshot/compose/performScreenshot.kt#L72
The WindowAttachment.dispatchAttach fakes a window, which might be good enough to render the react component instead of using a real window. See if you can make it work with that.
| .setExactWidthPx(1000) | ||
| .layout() | ||
| val view = surface.view!! | ||
| val wm = context.getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager |
There was a problem hiding this comment.
the moment you use WindowManager this is no longer the solution we're looking for
|
|
||
| val ti = surface.start() | ||
| assertGoodTask(ti) | ||
| assertTrue("Timed out waiting for first render", renderLatch.await(10, TimeUnit.SECONDS)) |
There was a problem hiding this comment.
if we're doing this deterministically, we shouldn't have to do concurrency.. ideally.
Replaces the TYPE_APPLICATION_OVERLAY window (which required SYSTEM_ALERT_WINDOW permission) with WindowAttachment.dispatchAttach, which fakes window attachment via reflection without a real window. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| Screenshot.snap(surface.view!!) | ||
| .record() | ||
| assertTrue("Timed out waiting for first render", renderLatch.await(10, TimeUnit.SECONDS)) | ||
| Screenshot.snap(view).record() |
There was a problem hiding this comment.
do the Screenshot.snap inside the instrumentation.runOnMainSync, so that there's one less source of non-determinism
Screenshot.snap was being called on the test thread after the latch released. Moving it into runOnMainSync ensures the view is snapped on the UI thread, removing one source of non-determinism. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| Screenshot.snap(surface.view!!) | ||
| .record() | ||
| assertTrue("Timed out waiting for first render", renderLatch.await(10, TimeUnit.SECONDS)) | ||
| instrumentation.runOnMainSync { | ||
| Screenshot.snap(view).record() | ||
| } |
There was a problem hiding this comment.
move the what happens if all of this is inside the same runOnMainSync?
Also, give the .snap(view).setName(...) a name. Since it's no longer running in the test thread, it can't automatically guess a name. That's why the screenshot got renamed.
There was a problem hiding this comment.
You haven't really change this, it's just written a different way.
Because you have the latch, it's hard to tell if the OnGlobalLayoutListener is the actual fix or the timing just so happens that that seems to fix it.
One thing you can do is try moving the Screenshot.snap inside the OnGlobalLayout(). If that works, then we can be a bit more confident that the onGlobalLayout is related to the fix. But, it's not the ideal final state we want to be in.
There was a problem hiding this comment.
We tried this — blank screenshot. Also tried OnPreDrawListener — same result. view.draw() inside those callbacks returns blank because the view hasn't been through a real hardware draw cycle yet. That confirmed the listener itself wasn't the fix.
Per Arnold's review: collapse surface.start() and Screenshot.snap() into one runOnMainSync to reduce non-determinism, and pass an explicit name since snap() can't infer the test name from the main thread. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use OnGlobalLayoutListener + CountDownLatch instead of taking the screenshot immediately after surface.start(). The latch releases once the surface view has children, which signals that React Native has rendered content. 30s timeout acts as a safeguard if rendering never completes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| Screenshot.snap(surface.view!!) | ||
| .record() | ||
| assertTrue("Timed out waiting for first render", renderLatch.await(10, TimeUnit.SECONDS)) | ||
| instrumentation.runOnMainSync { | ||
| Screenshot.snap(view).record() | ||
| } |
There was a problem hiding this comment.
You haven't really change this, it's just written a different way.
Because you have the latch, it's hard to tell if the OnGlobalLayoutListener is the actual fix or the timing just so happens that that seems to fix it.
One thing you can do is try moving the Screenshot.snap inside the OnGlobalLayout(). If that works, then we can be a bit more confident that the onGlobalLayout is related to the fix. But, it's not the ideal final state we want to be in.
| .layout() | ||
| view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { | ||
| override fun onGlobalLayout() { | ||
| if (view.childCount > 0) { |
There was a problem hiding this comment.
this is interesting, does the view.childCount ever come in at zero?
If that's the case, you need to dig deeper into the React Native code to figure out how to trigger the view construction manually without relying on the window callbacks.
There was a problem hiding this comment.
Yes — onGlobalLayout never fired with childCount > 0. We believe WindowAttachment.dispatchAttach doesn't set up the full window infrastructure needed for layout callbacks to propagate. We confirmed Fabric does call addView() (by reading SurfaceMountingManager), so we switched to directly polling view.childCount on the main thread every 50ms after the start task completes. This reliably detects when Fabric has mounted views and brings test time from ~30s (accidental sleep) down to ~1.6s.
| assertNotNull(surface.view) | ||
| try { | ||
| instrumentation.runOnMainSync { | ||
| reactHost.onHostResume(null) |
There was a problem hiding this comment.
this is also interesting. I wonder if the onHostResume depends on the Window being attached for it to do anything. Is there a reason this is before dispatchAttach?
There was a problem hiding this comment.
Good catch. We tested moving it after dispatchAttach — no behavioral difference. Looking at the source, onHostResume calls moveToOnHostResume(currentReactContext, activity) but currentReactContext is null at that point (surface hasn't started yet), so it's effectively a no-op either way. We've reordered it after dispatchAttach for correctness of intent.
Replace window callbacks (OnGlobalLayoutListener) with direct polling of view.childCount on the main thread every 50ms. The listener never fired because WindowAttachment.dispatchAttach doesn't set up the full window infrastructure needed for layout callbacks. Polling works because Fabric does call addView() when mounting views, so childCount > 0 is a reliable signal that rendering has completed. Screenshot is taken immediately after children appear, giving a ~1.6s test time vs the previous 30s accidental sleep. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
MainApplicationinconstructViewTestby constructingReactHostImpldirectly in the testReactHostImplexplicitly, which is the bridgeless New Architecture implementation (no separate flag needed in RN 0.74+)Notes
DefaultReactHost.getDefaultReactHostis a singleton, so the only way to get an isolated host in the test is to constructReactHostImpldirectly. This requires@OptIn(UnstableReactNativeAPI::class).The screenshot currently captures a blank view — this is a pre-existing timing issue (the screenshot is taken before Fabric commits the first JS render). That will be addressed in a follow-up PR.
Test plan
constructViewTest— should pass with 0 failures🤖 Generated with Claude Code