Skip to content

constructViewTest: create ReactHostImpl directly (bridgeless)#103

Open
EmilioBejasa wants to merge 8 commits intomainfrom
bridgeless-isolated-construct-view-test
Open

constructViewTest: create ReactHostImpl directly (bridgeless)#103
EmilioBejasa wants to merge 8 commits intomainfrom
bridgeless-isolated-construct-view-test

Conversation

@EmilioBejasa
Copy link
Copy Markdown
Collaborator

Summary

  • Removes the dependency on MainApplication in constructViewTest by constructing ReactHostImpl directly in the test
  • Uses ReactHostImpl explicitly, which is the bridgeless New Architecture implementation (no separate flag needed in RN 0.74+)
  • Disables dev support and packager access for the test host

Notes

DefaultReactHost.getDefaultReactHost is a singleton, so the only way to get an isolated host in the test is to construct ReactHostImpl directly. 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

  • Run constructViewTest — should pass with 0 failures

🤖 Generated with Claude Code

EmilioBejasa and others added 3 commits April 3, 2026 12:22
…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>
Copy link
Copy Markdown
Contributor

@tdrhq tdrhq left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@EmilioBejasa EmilioBejasa requested a review from tdrhq April 7, 2026 15:56
Screenshot.snap(surface.view!!)
.record()
assertTrue("Timed out waiting for first render", renderLatch.await(10, TimeUnit.SECONDS))
Screenshot.snap(view).record()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Comment on lines -46 to +93
Screenshot.snap(surface.view!!)
.record()
assertTrue("Timed out waiting for first render", renderLatch.await(10, TimeUnit.SECONDS))
instrumentation.runOnMainSync {
Screenshot.snap(view).record()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

@tdrhq tdrhq Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

EmilioBejasa and others added 2 commits April 7, 2026 18:37
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>
@EmilioBejasa EmilioBejasa requested a review from tdrhq April 8, 2026 16:58
Comment on lines -46 to +93
Screenshot.snap(surface.view!!)
.record()
assertTrue("Timed out waiting for first render", renderLatch.await(10, TimeUnit.SECONDS))
instrumentation.runOnMainSync {
Screenshot.snap(view).record()
}
Copy link
Copy Markdown
Contributor

@tdrhq tdrhq Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@EmilioBejasa EmilioBejasa requested a review from tdrhq April 8, 2026 19:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants