From 985e9946924274a34f2a45d98b877d2ac776d705 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 27 Mar 2026 16:08:00 -0400 Subject: [PATCH 1/3] experiment: eager story prep at configure() time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kicks off loadStory() for all stories immediately in configure(), using _preview.storyStoreValue directly (bypasses _preview.ready() which only resolves when the Storybook UI renders — never in a test run). By the time StoryRenderer mounts, _idToPrepared is already fully populated, so the render effect is synchronous (no await in the hot path). Combined with the JS-driven Promise handshake from the previous experiment, this removes all async work between "set story" and "notify native ready". Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 146 +++++++------- .../StorybookRegistry.kt | 56 ++++-- .../src/StoryRenderer.tsx | 178 +++++++++++------- 3 files changed, 219 insertions(+), 161 deletions(-) diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt index 546b3ce..5f2b938 100644 --- a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt +++ b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt @@ -3,7 +3,10 @@ package com.rnstorybookautoscreenshots import android.Manifest import android.graphics.PixelFormat import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log +import android.view.Choreographer import android.view.ContextThemeWrapper import android.view.View import android.view.WindowManager @@ -16,10 +19,10 @@ import com.facebook.testing.screenshot.ViewHelpers import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -import java.io.File +import java.util.concurrent.CountDownLatch /** - * Base screenshot test that automatically discovers and tests all Storybook stories. + * Base screenshot test that automatically renders and screenshots all Storybook stories. * * Extend this class in your app's androidTest directory: * @@ -28,17 +31,16 @@ import java.io.File * class StoryScreenshotTest : BaseStoryScreenshotTest() * ``` * - * This test automatically bootstraps the story manifest if it doesn't exist, - * then creates a screenshot for each story. No manual test methods needed - - * just add stories to Storybook and they get tested automatically. + * A single React surface is mounted for the entire test run. JS drives the story + * loop — rendering each story and calling notifyStoryReady() after React commits. + * The test thread screenshots and then resolves the JS Promise to advance the loop. + * When all stories are done JS calls allStoriesDone() and the test exits. */ abstract class BaseStoryScreenshotTest { companion object { private const val TAG = "BaseStoryScreenshotTest" private const val DEFAULT_LOAD_TIMEOUT_MS = 5000L - private const val DEFAULT_BOOTSTRAP_TIMEOUT_MS = 10000L - private const val BOOTSTRAP_STORY_NAME = "__bootstrap__" private const val SCREEN_WIDTH_PX = 1080 private const val SCREEN_HEIGHT_PX = 1920 @@ -58,102 +60,84 @@ abstract class BaseStoryScreenshotTest { open fun getMainComponentName(): String = "StoryRenderer" /** - * Override to customize the React Native load timeout per story. + * Override to customize the per-story timeout. * Default is 5000ms. */ open fun getLoadTimeoutMs(): Long = DEFAULT_LOAD_TIMEOUT_MS /** - * Override to customize the timeout for manifest bootstrap. - * Default is 10000ms. - */ - open fun getBootstrapTimeoutMs(): Long = DEFAULT_BOOTSTRAP_TIMEOUT_MS - - /** - * Override to filter which stories should be screenshotted. + * Override to skip specific stories. * Return true to include the story, false to skip it. * Default includes all stories. */ - open fun shouldScreenshotStory(storyInfo: StoryInfo): Boolean = true + open fun shouldScreenshotStory(storyId: String): Boolean = true /** - * Screenshots all stories found in the manifest. - * Each story gets its own screenshot named after its ID. - * If the manifest doesn't exist, it will be bootstrapped automatically. + * Screenshots all Storybook stories. + * + * Mounts a single StoryRenderer surface. JS iterates through all stories, + * calling notifyStoryReady() after each commit. The test thread screenshots + * and resolves the Promise to let JS advance. */ @Test fun screenshotAllStories() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val externalDir = context.getExternalFilesDir("screenshots") - val manifestFile = File(externalDir, StorybookRegistry.STORIES_FILE_NAME) - - if (!manifestFile.exists()) { - Log.d(TAG, "Manifest not found, bootstrapping...") - bootstrapManifest(manifestFile) + mountSurface { view -> + runStoryLoop(view) } + } - val allStories = StorybookRegistry.getStoriesFromFile(externalDir!!) - val stories = allStories.filter { shouldScreenshotStory(it) } + private fun runStoryLoop(view: View) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val failures = mutableListOf() + var successCount = 0 - Log.d(TAG, "Found ${allStories.size} stories, ${stories.size} after filtering") - assertTrue("No stories found in manifest", stories.isNotEmpty()) + while (true) { + StorybookRegistry.prepareForNextStory() + val storyId = StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) ?: break - var successCount = 0 - var failureCount = 0 - val failures = mutableListOf() + if (!shouldScreenshotStory(storyId)) { + Log.d(TAG, "Skipping story: $storyId") + StorybookRegistry.resolveCurrentStory() + continue + } - for (story in stories) { + Log.d(TAG, "Screenshotting: $storyId") try { - screenshotStory(story) + // Wait for Fabric to apply native mutations before snapping. + waitTwoFrames() + val screenshotName = storyId.replace("--", "_") + instrumentation.runOnMainSync { + Screenshot.snap(view).setName(screenshotName).record() + } + Log.d(TAG, "Screenshot captured: $screenshotName") successCount++ } catch (e: Exception) { - failureCount++ - val errorMsg = "${story.title}/${story.name}: ${e.message}" - failures.add(errorMsg) - Log.e(TAG, "Failed to screenshot story: $errorMsg", e) + failures.add("$storyId: ${e.message}") + Log.e(TAG, "Failed to screenshot story: $storyId", e) + } finally { + StorybookRegistry.resolveCurrentStory() } } - Log.d(TAG, "Screenshot results: $successCount passed, $failureCount failed") + Log.d(TAG, "Screenshot results: $successCount passed, ${failures.size} failed") if (failures.isNotEmpty()) { Log.e(TAG, "Failed stories:\n${failures.joinToString("\n")}") } + assertTrue("No stories were screenshotted", successCount > 0) assertTrue( "Some stories failed to screenshot: ${failures.joinToString(", ")}", failures.isEmpty() ) } - private fun screenshotStory(storyInfo: StoryInfo) { - val storyName = storyInfo.toStoryName() - Log.d(TAG, "Screenshotting: $storyName (id: ${storyInfo.id})") - - StorybookRegistry.prepareForNextStory() - renderStory(storyName) { view -> - StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) - val screenshotName = storyInfo.id.replace("--", "_") - Screenshot.snap(view).setName(screenshotName).record() - Log.d(TAG, "Screenshot captured: $screenshotName") - } - } - - private fun bootstrapManifest(manifestFile: File) { - Log.d(TAG, "Launching StoryRenderer to generate manifest...") - renderStory(BOOTSTRAP_STORY_NAME) { - waitForManifestFile(manifestFile) - } - Log.d(TAG, "Bootstrap complete") - } - /** - * Renders the given story name into a view, calls [onRendered] with that view, - * then tears down. Handles both old arch (ReactRootView) and new arch (ReactSurface). + * Mounts the StoryRenderer surface, calls [onMounted] with the view, then tears down. + * Handles both new arch (ReactHost/ReactSurface) and old arch (ReactRootView). */ - private fun renderStory(storyName: String, onRendered: (view: View) -> Unit) { + private fun mountSurface(onMounted: (view: View) -> Unit) { val instrumentation = InstrumentationRegistry.getInstrumentation() val app = instrumentation.targetContext.applicationContext as ReactApplication - val props = Bundle().apply { putString("storyName", storyName) } val reactHost = app.reactHost if (reactHost != null) { @@ -169,7 +153,7 @@ abstract class BaseStoryScreenshotTest { val surface = reactHost.createSurface( context, getMainComponentName(), - props + Bundle() ) val view = surface.view @@ -194,7 +178,7 @@ abstract class BaseStoryScreenshotTest { surface.start() } - onRendered(view) + onMounted(view) instrumentation.runOnMainSync { surface.stop() @@ -210,7 +194,7 @@ abstract class BaseStoryScreenshotTest { // ReactRootView.startReactApplication() checks isOnUiThread() internally. instrumentation.runOnMainSync { - rootView.startReactApplication(reactInstanceManager, getMainComponentName(), props) + rootView.startReactApplication(reactInstanceManager, getMainComponentName(), Bundle()) } // setupView().layout() calls measure()+layout() at the fixed dimensions, which @@ -220,22 +204,26 @@ abstract class BaseStoryScreenshotTest { .setExactHeightPx(SCREEN_HEIGHT_PX) .layout() - onRendered(rootView) + onMounted(rootView) instrumentation.runOnMainSync { rootView.unmountReactApplication() } } } - private fun waitForManifestFile(manifestFile: File) { - val deadline = System.currentTimeMillis() + getBootstrapTimeoutMs() - while (!manifestFile.exists() && System.currentTimeMillis() < deadline) { - Thread.sleep(100) - } - if (!manifestFile.exists()) { - throw IllegalStateException( - "Manifest file did not appear within ${getBootstrapTimeoutMs()}ms. " + - "Make sure configure(view) is called in your app and the StoryRenderer is registered." - ) + /** + * Waits for two Choreographer frames on the main thread. + * + * After useEffect fires (React commit), Fabric still needs to apply its + * native mutations in the next frame(s). Waiting two frames ensures the + * shadow tree is fully flushed to native views before we screenshot. + */ + private fun waitTwoFrames() { + repeat(2) { + val latch = CountDownLatch(1) + Handler(Looper.getMainLooper()).post { + Choreographer.getInstance().postFrameCallback { latch.countDown() } + } + latch.await() } } } diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/StorybookRegistry.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/StorybookRegistry.kt index 1dfb0c9..ce3c184 100644 --- a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/StorybookRegistry.kt +++ b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/StorybookRegistry.kt @@ -1,6 +1,7 @@ package com.rnstorybookautoscreenshots import android.util.Log +import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod @@ -10,14 +11,19 @@ import org.json.JSONObject import java.io.File import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import com.facebook.react.modules.core.DeviceEventManagerModule /** * Native module with two responsibilities: * - Receives the story list from JS and writes it to disk for test discovery. - * - Synchronises the test thread with JS rendering via a CountDownLatch. + * - Synchronises the test thread with JS rendering via a CountDownLatch/Promise handshake. + * + * Protocol per story: + * 1. Test calls prepareForNextStory() — arms a fresh latch. + * 2. JS calls notifyStoryReady(id, promise) — stores promise, counts down latch. + * 3. Test calls awaitStoryReady(timeout) — blocks until latch fires, returns story id. + * 4. Test screenshots, then calls resolveCurrentStory() — resolves the JS promise. + * 5. JS advances to the next story (repeat from 1), or calls allStoriesDone(). */ - class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { companion object { @@ -25,24 +31,39 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas const val STORIES_FILE_NAME = "storybook_stories.json" @Volatile private var storyReadyLatch: CountDownLatch? = null + @Volatile private var pendingStoryId: String? = null + @Volatile private var pendingPromise: Promise? = null + @Volatile private var isDone = false /** - * Call before rendering each story. Creates a fresh latch for [awaitStoryReady]. + * Reset state and arm a fresh latch. Call before each story. */ fun prepareForNextStory() { storyReadyLatch = CountDownLatch(1) } /** - * Blocks until JS signals the story is rendered, or the timeout elapses. + * Blocks until JS calls notifyStoryReady (or allStoriesDone), or the timeout elapses. + * Returns the story id, or null if all stories are done or the timeout elapsed. */ - fun awaitStoryReady(timeoutMs: Long) { + fun awaitStoryReady(timeoutMs: Long): String? { storyReadyLatch?.await(timeoutMs, TimeUnit.MILLISECONDS) + return if (isDone) null else pendingStoryId + } + + /** + * Resolves the Promise that JS is awaiting, letting it advance to the next story. + * Call this after the screenshot has been captured. + */ + fun resolveCurrentStory() { + pendingPromise?.resolve(null) + pendingPromise = null + pendingStoryId = null } /** * Read stories from the manifest file. - * Used by screenshot tests to get list of all stories. + * Used by screenshot tests to get the list of all stories. */ fun getStoriesFromFile(storageDir: File): List { val file = File(storageDir, STORIES_FILE_NAME) @@ -72,21 +93,26 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas override fun getName(): String = "StorybookRegistry" /** - * Called from JS when a story has finished rendering (or errored). - * Releases the latch that screenshotStory() is waiting on. + * Called from JS after React commits a story render. + * Stores the promise (resolved later by resolveCurrentStory) and unblocks the test thread. */ @ReactMethod - fun notifyStoryReady() { + fun notifyStoryReady(storyId: String, promise: Promise) { + pendingStoryId = storyId + pendingPromise = promise storyReadyLatch?.countDown() } - fun loadStory(storyName: String) { - reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - ?.emit("loadStory", storyName) + /** + * Called from JS when all stories have been rendered. + * Unblocks awaitStoryReady so the test loop can exit. + */ + @ReactMethod + fun allStoriesDone() { + isDone = true + storyReadyLatch?.countDown() } - /** * Called from JS to register the list of available stories. * Writes to external files directory for test access. diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index c45f540..8b3ba74 100644 --- a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx +++ b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx @@ -1,15 +1,25 @@ -import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, NativeModules, NativeEventEmitter } from 'react-native'; -import { storyNameToId } from './utils'; +import React, { useEffect, useState, useRef } from 'react'; +import { View, Text, StyleSheet, NativeModules } from 'react-native'; const { StorybookRegistry } = NativeModules; let storybookView: any = null; +/** + * Promise that resolves once all stories have been loaded into _idToPrepared. + * Kicked off synchronously in configure() so it is resolved (or nearly so) + * before StoryRenderer ever mounts. + */ +let eagerPrepPromise: Promise | null = null; + /** * Configure the library with the Storybook view instance. * Call this once during app initialization. * + * Immediately starts loading all stories into _idToPrepared using + * storyStoreValue — bypassing _preview.ready() which only resolves + * when the Storybook UI renders (never in a test run). + * * @example * import { view } from './.rnstorybook/storybook.requires'; * import { configure } from 'rn-storybook-auto-screenshots'; @@ -17,11 +27,22 @@ let storybookView: any = null; */ export function configure(view: any) { storybookView = view; -} -type StoryRendererProps = { - storyName?: string; -}; + // importFn is `async path => importMap[path]` — the map is already loaded + // synchronously at app startup, so each loadStory() resolves in one microtask. + // We fan-out all calls immediately so they run in parallel. + const storyStore = view._preview?.storyStoreValue; + if (storyStore && view._storyIndex?.entries) { + const storyIds = Object.keys(view._storyIndex.entries); + eagerPrepPromise = Promise.all( + storyIds.map((id: string) => storyStore.loadStory({ storyId: id })) + ).then((prepared: any[]) => { + storyIds.forEach((id: string, i: number) => { + view._idToPrepared[id] = prepared[i]; + }); + }); + } +} /** * Register all available stories with the native module. @@ -50,80 +71,103 @@ export function registerStoriesWithNative() { } /** - * Renders individual Storybook stories for screenshot testing. - * Uses Storybook's actual rendering pipeline. + * Renders all Storybook stories sequentially for screenshot testing. + * + * Story preparation is done eagerly at configure() time, so the render + * effect for each story is fully synchronous — no async/await in the + * hot path between "set story" and "notify native". * - * @param storyName - Format: "ComponentName/StoryName" (e.g., "MyFeature/Initial") + * Protocol with native: + * JS calls StorybookRegistry.notifyStoryReady(id) → Promise + * Native screenshots, then resolves the Promise + * JS advances to the next story + * When all stories are done, JS calls StorybookRegistry.allStoriesDone() */ -export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRendererProps) { +export function StoryRenderer() { + const [currentStoryId, setCurrentStoryId] = useState(null); const [storyContent, setStoryContent] = useState(null); const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect (() => { - const emitter = new NativeEventEmitter(NativeModules.StorybookRegistry); - const sub = emitter.addListener('loadStory', (name: string) => { - console.log('loadStory event received:', name); - }); - return () => sub.remove(); - }, []); - // Notify native after React's commit phase so the test thread can proceed to screenshot. + // Stable refs so effect callbacks always see the latest values without + // needing them in their dependency arrays. + const storiesRef = useRef>([]); + const indexRef = useRef(-1); + + // After React commits a story render (or error), notify native so the test + // thread can screenshot, then advance to the next story once it resolves. useEffect(() => { - if (!loading) { - StorybookRegistry.notifyStoryReady(); - } - }, [loading]); + if (indexRef.current < 0) return; + const story = storiesRef.current[indexRef.current]; + if (!story) return; + + StorybookRegistry.notifyStoryReady(story.id).then(() => { + const next = indexRef.current + 1; + indexRef.current = next; + if (next < storiesRef.current.length) { + setCurrentStoryId(storiesRef.current[next].id); + } else { + StorybookRegistry.allStoriesDone(); + } + }); + }, [storyContent, error]); + // Render the current story synchronously. + // _idToPrepared is fully populated by eagerPrepPromise before we get here, + // so no await is needed — this effect is pure synchronous state work. useEffect(() => { - async function renderStory() { - try { - if (!storybookView) { - setError('Storybook not configured. Call configure(view) first.'); - setLoading(false); - return; - } - - // Register all stories with native module for test discovery. - // _storyIndex is set synchronously by Storybook's start(), so this - // doesn't need to wait for createPreparedStoryMapping(). - registerStoriesWithNative(); - - const storyId = storyNameToId(storyName); - - // Lazily populate _idToPrepared — createPreparedStoryMapping() is async. - if (!storybookView._idToPrepared || Object.keys(storybookView._idToPrepared).length === 0) { - await storybookView.createPreparedStoryMapping(); - } - - const preparedStory = storybookView._idToPrepared[storyId]; - - if (!preparedStory) { - const availableStories = Object.keys(storybookView._idToPrepared || {}).join(', '); - setError(`Story "${storyId}" not found. Available: ${availableStories}`); - setLoading(false); - return; - } - - const storyContext = storybookView._preview.getStoryContext(preparedStory); - const { unboundStoryFn: StoryComponent } = preparedStory; - const rendered = ; - - setStoryContent(rendered); - setLoading(false); - } catch (e) { - setError(`Error rendering story: ${e}`); - setLoading(false); + if (currentStoryId === null) return; + try { + const preparedStory = storybookView._idToPrepared[currentStoryId]; + if (!preparedStory) { + setError(`Story "${currentStoryId}" not found`); + setStoryContent(null); + return; } + const storyContext = storybookView._preview.getStoryContext(preparedStory); + const { unboundStoryFn: StoryComponent } = preparedStory; + setError(null); + setStoryContent(); + } catch (e) { + setError(`Error rendering story: ${e}`); + setStoryContent(null); } + }, [currentStoryId]); - renderStory(); - }, [storyName]); + // Bootstrap: register stories with native, wait for eager prep (safety net + // in case configure() was called very close to mount), then start the loop. + useEffect(() => { + async function init() { + if (!storybookView) { + setError('Storybook not configured. Call configure(view) first.'); + return; + } + + // Write the manifest synchronously — _storyIndex is available immediately. + registerStoriesWithNative(); + + // Await eager prep. In normal usage this is already resolved by now. + if (eagerPrepPromise) { + await eagerPrepPromise; + } + + const stories = getAllStories(); + storiesRef.current = stories; + + if (stories.length === 0) { + StorybookRegistry.allStoriesDone(); + return; + } + + indexRef.current = 0; + setCurrentStoryId(stories[0].id); + } + init(); + }, []); - if (loading) { + if (indexRef.current < 0) { return ( - Loading story... + Loading stories... ); } From aa48c7cdba169b94cba14a8e2e25cacb135fbef7 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Mar 2026 12:46:52 -0400 Subject: [PATCH 2/3] fix: add ViewHelpers layout before Screenshot.snap on new arch Without an explicit measure/layout pass, Screenshot.snap(surface.view) captures a blank bitmap on Fabric. Mirror what the old arch path already does with ViewHelpers so the view tree is software-rendered and properly sized before capture. Co-Authored-By: Claude Sonnet 4.6 --- .../com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt index 5f2b938..9aeab22 100644 --- a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt +++ b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt @@ -107,6 +107,10 @@ abstract class BaseStoryScreenshotTest { waitTwoFrames() val screenshotName = storyId.replace("--", "_") instrumentation.runOnMainSync { + ViewHelpers.setupView(view) + .setExactWidthPx(SCREEN_WIDTH_PX) + .setExactHeightPx(SCREEN_HEIGHT_PX) + .layout() Screenshot.snap(view).setName(screenshotName).record() } Log.d(TAG, "Screenshot captured: $screenshotName") From c573f4107aac77dbd115afe8fd50a71d440c2bdb Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Mar 2026 14:04:10 -0400 Subject: [PATCH 3/3] fix: force software layer recursively before snapping screenshots view.draw(canvas) skips Fabric child views that have hardware display lists, leaving only the root container's white background. Walking the entire view tree and setting LAYER_TYPE_SOFTWARE on every node ensures all content is visible to the software canvas used by Screenshot.snap(). Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt index 9aeab22..8cb3a27 100644 --- a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt +++ b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt @@ -9,13 +9,13 @@ import android.util.Log import android.view.Choreographer import android.view.ContextThemeWrapper import android.view.View +import android.view.ViewGroup import android.view.WindowManager import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.facebook.react.ReactApplication import com.facebook.react.ReactRootView import com.facebook.testing.screenshot.Screenshot -import com.facebook.testing.screenshot.ViewHelpers import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -107,10 +107,7 @@ abstract class BaseStoryScreenshotTest { waitTwoFrames() val screenshotName = storyId.replace("--", "_") instrumentation.runOnMainSync { - ViewHelpers.setupView(view) - .setExactWidthPx(SCREEN_WIDTH_PX) - .setExactHeightPx(SCREEN_HEIGHT_PX) - .layout() + setLayerTypeSoftwareRecursively(view) Screenshot.snap(view).setName(screenshotName).record() } Log.d(TAG, "Screenshot captured: $screenshotName") @@ -214,6 +211,23 @@ abstract class BaseStoryScreenshotTest { } } + /** + * Recursively sets LAYER_TYPE_SOFTWARE on a view and all its descendants. + * + * view.draw(canvas) cannot capture children that have hardware display lists. + * Fabric child views in a hardware-accelerated WindowManager window get hardware + * display lists by default, so they render blank into a software canvas. + * Walking the tree and forcing software layers ensures draw() sees all content. + */ + private fun setLayerTypeSoftwareRecursively(view: View) { + view.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + setLayerTypeSoftwareRecursively(view.getChildAt(i)) + } + } + } + /** * Waits for two Choreographer frames on the main thread. *