From 5fcd9122c2ffb48591623956f63b2e56fff2b6db Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 27 Mar 2026 12:49:23 -0400 Subject: [PATCH 1/4] experiment: JS-driven story loop with Promise handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS owns the entire story sequence: createPreparedStoryMapping() once, then for (story of allStories) { render; await notifyStoryReady(id) }. notifyStoryReady() is a Promise call — native resolves it after taking the screenshot, letting JS advance to the next story. No event emitter, no isBlockingSynchronousMethod, no manifest pre-load. renderResolverRef bridges the async loop to React's useEffect commit phase. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 181 +++++++----------- .../StorybookRegistry.kt | 80 ++++++-- .../src/StoryRenderer.tsx | 127 ++++++------ 3 files changed, 206 insertions(+), 182 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..538ab4a 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 @@ -4,6 +4,7 @@ import android.Manifest import android.graphics.PixelFormat import android.os.Bundle import android.util.Log +import android.view.Choreographer import android.view.ContextThemeWrapper import android.view.View import android.view.WindowManager @@ -16,7 +17,8 @@ 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 +import java.util.concurrent.TimeUnit /** * Base screenshot test that automatically discovers and tests all Storybook stories. @@ -28,17 +30,23 @@ 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. + * Experiment: js-driven-story-loop + * --------------------------------- + * JS drives the entire story sequence. StoryRenderer renders all stories one at a + * time in a for loop, calling notifyStoryReady(storyId) after each render and + * waiting for the Promise to resolve before moving to the next story. + * + * The test thread loops reactively: wait for notifyStoryReady → take screenshot → + * resolve the Promise → repeat until allStoriesDone(). + * + * No events, no isBlockingSynchronousMethod, no manifest pre-loading step. */ 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 DEFAULT_TOTAL_TIMEOUT_MS = 300_000L // 5 minutes for all stories private const val SCREEN_WIDTH_PX = 1080 private const val SCREEN_HEIGHT_PX = 1920 @@ -58,110 +66,73 @@ abstract class BaseStoryScreenshotTest { open fun getMainComponentName(): String = "StoryRenderer" /** - * Override to customize the React Native load timeout per story. + * Override to customize the per-story screenshot 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. - * Return true to include the story, false to skip it. - * Default includes all stories. - */ - open fun shouldScreenshotStory(storyInfo: StoryInfo): 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 stories. JS tells us which story to screenshot and when — + * the test thread just reacts to notifyStoryReady() calls. */ @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) - } - - val allStories = StorybookRegistry.getStoriesFromFile(externalDir!!) - val stories = allStories.filter { shouldScreenshotStory(it) } + val instrumentation = InstrumentationRegistry.getInstrumentation() - Log.d(TAG, "Found ${allStories.size} stories, ${stories.size} after filtering") - assertTrue("No stories found in manifest", stories.isNotEmpty()) + StorybookRegistry.prepareForRun() - var successCount = 0 - var failureCount = 0 val failures = mutableListOf() - for (story in stories) { - try { - screenshotStory(story) - 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) + mountSurface { view -> + // React to stories as JS renders them, until allStoriesDone() is called. + while (true) { + StorybookRegistry.prepareForNextStory() + val storyId = StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) + + if (storyId == null) { + // allStoriesDone() was called — JS has finished. + Log.d(TAG, "All stories done") + break + } + + try { + // Two frames so Fabric's native view mutations are fully applied. + waitTwoFrames() + + val screenshotName = storyId.replace("--", "_") + instrumentation.runOnMainSync { + Screenshot.snap(view).setName(screenshotName).record() + } + Log.d(TAG, "Screenshot captured: $screenshotName") + } catch (e: Exception) { + failures.add("$storyId: ${e.message}") + Log.e(TAG, "Failed to screenshot story: $storyId", e) + } finally { + // Resolve the notifyStoryReady() Promise so JS can render the next story. + StorybookRegistry.resolveCurrentStory() + } } } - Log.d(TAG, "Screenshot results: $successCount passed, $failureCount failed") - if (failures.isNotEmpty()) { - Log.e(TAG, "Failed stories:\n${failures.joinToString("\n")}") - } - + Log.d(TAG, "${failures.size} stories failed") 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 a single React surface for the whole test run, calls [onMounted] with + * the view, then tears down. Handles both old arch (ReactRootView) and new arch + * (ReactSurface). No props are passed — JS drives itself. */ - 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) { // New arch (Fabric/bridgeless): ReactHost + ReactSurface. - // Fabric won't commit its render tree until the surface's host view is parented - // to a real Window. Test processes don't have an Activity window, so we attach - // via WindowManager using TYPE_APPLICATION_OVERLAY (requires SYSTEM_ALERT_WINDOW). - // Wrap with the app theme so AppCompat widgets (e.g. Switch) resolve styled attrs. val context = ContextThemeWrapper( instrumentation.targetContext, instrumentation.targetContext.applicationInfo.theme @@ -169,7 +140,7 @@ abstract class BaseStoryScreenshotTest { val surface = reactHost.createSurface( context, getMainComponentName(), - props + Bundle() ) val view = surface.view @@ -186,19 +157,18 @@ abstract class BaseStoryScreenshotTest { ) instrumentation.runOnMainSync { - // Force software rendering so Screenshot.snap() can capture via draw(canvas). - // WindowManager views are hardware-accelerated by default; GPU content is - // invisible to a software canvas. view.setLayerType(View.LAYER_TYPE_SOFTWARE, null) wm.addView(view, params) surface.start() } - onRendered(view) - - instrumentation.runOnMainSync { - surface.stop() - wm.removeView(view) + try { + onMounted(view) + } finally { + instrumentation.runOnMainSync { + surface.stop() + wm.removeView(view) + } } } else { // Old arch: ReactRootView + ReactInstanceManager (deprecated API). @@ -208,34 +178,31 @@ abstract class BaseStoryScreenshotTest { @Suppress("DEPRECATION") val reactInstanceManager = app.reactNativeHost.reactInstanceManager - // 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 - // triggers onMeasure() → attachToReactInstanceManager() on the ReactRootView. ViewHelpers.setupView(rootView) .setExactWidthPx(SCREEN_WIDTH_PX) .setExactHeightPx(SCREEN_HEIGHT_PX) .layout() - onRendered(rootView) - - instrumentation.runOnMainSync { rootView.unmountReactApplication() } + try { + onMounted(rootView) + } finally { + 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." - ) + private fun waitTwoFrames() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + repeat(2) { + val latch = CountDownLatch(1) + instrumentation.runOnMainSync { + Choreographer.getInstance().postFrameCallback { latch.countDown() } + } + latch.await(1000, TimeUnit.MILLISECONDS) } } } 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..a6b9390 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,23 @@ 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: + * Native module with three 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. + * - Receives notifyStoryReady(storyId) from JS and holds the Promise until the + * test thread has taken the screenshot and calls resolveCurrentStory(). + * - Receives allStoriesDone() from JS to signal the test thread to exit. + * + * Communication flow: + * JS thread → notifyStoryReady(storyId, promise) → storyReadyLatch.countDown() + * Test thread ← awaitStoryReady() ← storyReadyLatch + * Test thread → takes screenshot → resolveCurrentStory() → promise.resolve() + * JS thread ← promise resolves → renders next story + * ... + * JS thread → allStoriesDone() → allDoneLatch.countDown() + * Test thread ← awaitAllDone() ← allDoneLatch */ - class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { companion object { @@ -25,19 +35,51 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas const val STORIES_FILE_NAME = "storybook_stories.json" @Volatile private var storyReadyLatch: CountDownLatch? = null + @Volatile private var allDoneLatch: CountDownLatch? = null + @Volatile private var pendingPromise: Promise? = null + @Volatile private var pendingStoryId: String? = null + @Volatile private var isDone = false + + /** + * Call once before mounting the surface to set up the all-done latch. + */ + fun prepareForRun() { + allDoneLatch = CountDownLatch(1) + isDone = false + } /** - * Call before rendering each story. Creates a fresh latch for [awaitStoryReady]. + * Call before each story to create a fresh latch for [awaitStoryReady]. */ fun prepareForNextStory() { storyReadyLatch = CountDownLatch(1) + isDone = false + pendingStoryId = null } /** - * 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 allStoriesDone() was called (or timeout). */ - fun awaitStoryReady(timeoutMs: Long) { + fun awaitStoryReady(timeoutMs: Long): String? { storyReadyLatch?.await(timeoutMs, TimeUnit.MILLISECONDS) + return if (isDone) null else pendingStoryId + } + + /** + * Resolves the Promise that notifyStoryReady() is holding. + * Call this after taking the screenshot to let JS proceed to the next story. + */ + fun resolveCurrentStory() { + pendingPromise?.resolve(null) + pendingPromise = null + } + + /** + * Blocks until JS calls allStoriesDone(), or the timeout elapses. + */ + fun awaitAllDone(timeoutMs: Long) { + allDoneLatch?.await(timeoutMs, TimeUnit.MILLISECONDS) } /** @@ -72,21 +114,29 @@ 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 a story has been rendered and committed to the view. + * Stores the Promise (so the test thread can resolve it after the screenshot) + * and releases [awaitStoryReady] so the test thread knows it can proceed. */ @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 after all stories have been rendered. + * Releases [awaitAllDone] so the test thread can exit and unmount the surface. + */ + @ReactMethod + fun allStoriesDone() { + isDone = true + // Release any thread waiting on awaitStoryReady (in case it's still waiting). + storyReadyLatch?.countDown() + allDoneLatch?.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..d37d042 100644 --- a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx +++ b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx @@ -1,6 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, NativeModules, NativeEventEmitter } from 'react-native'; -import { storyNameToId } from './utils'; +import React, { useEffect, useRef, useState } from 'react'; +import { View, Text, StyleSheet, NativeModules } from 'react-native'; const { StorybookRegistry } = NativeModules; @@ -19,10 +18,6 @@ export function configure(view: any) { storybookView = view; } -type StoryRendererProps = { - storyName?: string; -}; - /** * Register all available stories with the native module. * This allows the Android test to discover stories automatically. @@ -50,83 +45,95 @@ export function registerStoriesWithNative() { } /** - * Renders individual Storybook stories for screenshot testing. - * Uses Storybook's actual rendering pipeline. + * Renders all Storybook stories for screenshot testing, one at a time. + * + * JS drives the entire sequence — there are no incoming events from native. * - * @param storyName - Format: "ComponentName/StoryName" (e.g., "MyFeature/Initial") + * Flow: + * 1. Mount → register stories → createPreparedStoryMapping() once + * 2. for (story of allStories): + * a. setCurrentStoryId(story.id) → React renders the story + * b. useEffect fires after commit → resolves the "render done" Promise + * c. await notifyStoryReady(story.id) → native takes screenshot, + * then resolves this Promise so JS can proceed + * 3. allStoriesDone() — signals the test thread to exit and unmount */ -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(); - }, []); + // Holds the resolve function for the "render done" Promise created in runAllStories(). + // Set before setCurrentStoryId(), called after each commit in the effect below. + const renderResolverRef = useRef<(() => void) | null>(null); - // Notify native after React's commit phase so the test thread can proceed to screenshot. + // After each story is committed to the view, resolve the pending render Promise + // so the story loop can proceed to notify native. useEffect(() => { - if (!loading) { - StorybookRegistry.notifyStoryReady(); + if (renderResolverRef.current) { + renderResolverRef.current(); + renderResolverRef.current = null; } - }, [loading]); + }, [storyContent, error]); + // Render the story for the current storyId. useEffect(() => { + if (currentStoryId === null) return; + 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]; - + const preparedStory = storybookView._idToPrepared[currentStoryId!]; if (!preparedStory) { - const availableStories = Object.keys(storybookView._idToPrepared || {}).join(', '); - setError(`Story "${storyId}" not found. Available: ${availableStories}`); - setLoading(false); + const available = Object.keys(storybookView._idToPrepared || {}).join(', '); + setError(`Story "${currentStoryId}" not found. Available: ${available}`); + setStoryContent(null); return; } - const storyContext = storybookView._preview.getStoryContext(preparedStory); const { unboundStoryFn: StoryComponent } = preparedStory; - const rendered = ; - - setStoryContent(rendered); - setLoading(false); + setError(null); + setStoryContent(); } catch (e) { setError(`Error rendering story: ${e}`); - setLoading(false); + setStoryContent(null); } } renderStory(); - }, [storyName]); + }, [currentStoryId]); - if (loading) { - return ( - - Loading story... - - ); - } + // Main story loop — runs once on mount. + useEffect(() => { + async function runAllStories() { + if (!storybookView) { + setError('Storybook not configured. Call configure(view) first.'); + return; + } + + // Write the story list to disk and build the prepared story mapping. + registerStoriesWithNative(); + await storybookView.createPreparedStoryMapping(); + + const stories = getAllStories(); + for (const story of stories) { + // Ask React to render this story. + // The Promise resolves only after the commit (in the useEffect above), + // so we don't notify native until the view is actually painted. + await new Promise((resolve) => { + renderResolverRef.current = resolve; + setCurrentStoryId(story.id); + }); + + // Hand off to native — resolves when the test thread has taken the screenshot. + await StorybookRegistry.notifyStoryReady(story.id); + } + + // Tell the test thread there are no more stories. + StorybookRegistry.allStoriesDone(); + } + + runAllStories(); + }, []); if (error) { return ( From 725b997d5e51d0da4e5e3b5deaac92955e053205 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 27 Mar 2026 15:50:33 -0400 Subject: [PATCH 2/4] fix: bypass _preview.ready() and call notifyStoryReady directly in effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for blank screenshots: 1. buildPreparedStories() accesses _preview.storyStoreValue directly instead of calling createPreparedStoryMapping(), which waits on storeInitialization- Promise. That Promise only resolves when the Storybook UI renders — which never happens in our test scenario. The importFn is a sync map lookup so loadStory() resolves in the next microtask. 2. Remove renderResolverRef. Instead of resolving an intermediate Promise in useEffect and then calling notifyStoryReady from an async loop, call notifyStoryReady directly in useEffect([storyContent, error]). This matches how the main branch calls notifyStoryReady: immediately after the commit, with no extra microtask hop that could race against Fabric's native mutations. Co-Authored-By: Claude Sonnet 4.6 --- .../src/StoryRenderer.tsx | 132 +++++++++++------- 1 file changed, 79 insertions(+), 53 deletions(-) diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index d37d042..dc2cd38 100644 --- a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx +++ b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx @@ -44,95 +44,121 @@ export function registerStoriesWithNative() { } } +/** + * Builds the prepared story mapping without waiting for _preview.ready(). + * + * createPreparedStoryMapping() waits on storeInitializationPromise, which only + * resolves when the Storybook UI renders. In our test scenario the UI never + * renders, so it hangs. We bypass it by going directly to storyStoreValue, + * which is set synchronously during app startup before our surface mounts. + * The importFn is async (importPath) => importMap[importPath] — the map is + * eagerly loaded, so each loadStory call resolves in the next microtask. + */ +async function buildPreparedStories() { + if (Object.keys(storybookView._idToPrepared).length > 0) { + return; // already populated (e.g. by Storybook's own updateView()) + } + + const storyStore = storybookView._preview.storyStoreValue; + if (storyStore) { + await Promise.all( + Object.keys(storybookView._storyIndex.entries).map(async (storyId: string) => { + storybookView._idToPrepared[storyId] = await storyStore.loadStory({ storyId }); + }) + ); + } else { + // storyStore not yet set — fall back to the standard path. + await storybookView.createPreparedStoryMapping(); + } +} + /** * Renders all Storybook stories for screenshot testing, one at a time. * - * JS drives the entire sequence — there are no incoming events from native. + * JS drives the entire sequence — no events from native, no blocking sync calls. * * Flow: - * 1. Mount → register stories → createPreparedStoryMapping() once - * 2. for (story of allStories): - * a. setCurrentStoryId(story.id) → React renders the story - * b. useEffect fires after commit → resolves the "render done" Promise - * c. await notifyStoryReady(story.id) → native takes screenshot, - * then resolves this Promise so JS can proceed - * 3. allStoriesDone() — signals the test thread to exit and unmount + * 1. Mount → register stories → buildPreparedStories() (sync-ish, no ready() wait) + * 2. setCurrentStoryId(stories[0].id) → React renders + * 3. useEffect([storyContent, error]) fires after commit → notifyStoryReady(id) + * → native takes screenshot, resolves Promise → advance to next story + * 4. Repeat until all stories done → allStoriesDone() */ export function StoryRenderer() { const [currentStoryId, setCurrentStoryId] = useState(null); const [storyContent, setStoryContent] = useState(null); const [error, setError] = useState(null); - // Holds the resolve function for the "render done" Promise created in runAllStories(). - // Set before setCurrentStoryId(), called after each commit in the effect below. - const renderResolverRef = useRef<(() => void) | null>(null); + // Story list and current position, set once during init. + const storiesRef = useRef>([]); + const indexRef = useRef(-1); // -1 = not started yet - // After each story is committed to the view, resolve the pending render Promise - // so the story loop can proceed to notify native. + // After each story is committed to the view, notify native directly — + // no intermediate Promise, same pattern as the original notifyStoryReady call. + // When native resolves (screenshot taken), advance to the next story. useEffect(() => { - if (renderResolverRef.current) { - renderResolverRef.current(); - renderResolverRef.current = null; - } + if (indexRef.current < 0) return; // init hasn't set the first story yet + + 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 story for the current storyId. + // Render the prepared story synchronously — _idToPrepared is built before + // the first setCurrentStoryId call, so no async work needed here. useEffect(() => { if (currentStoryId === null) return; - async function renderStory() { - try { - const preparedStory = storybookView._idToPrepared[currentStoryId!]; - if (!preparedStory) { - const available = Object.keys(storybookView._idToPrepared || {}).join(', '); - setError(`Story "${currentStoryId}" not found. Available: ${available}`); - setStoryContent(null); - return; - } - const storyContext = storybookView._preview.getStoryContext(preparedStory); - const { unboundStoryFn: StoryComponent } = preparedStory; - setError(null); - setStoryContent(); - } catch (e) { - setError(`Error rendering story: ${e}`); + 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); } - - renderStory(); }, [currentStoryId]); - // Main story loop — runs once on mount. + // Bootstrap: build prepared stories once, then kick off the story loop. useEffect(() => { - async function runAllStories() { + async function init() { if (!storybookView) { setError('Storybook not configured. Call configure(view) first.'); return; } - // Write the story list to disk and build the prepared story mapping. registerStoriesWithNative(); - await storybookView.createPreparedStoryMapping(); + await buildPreparedStories(); const stories = getAllStories(); - for (const story of stories) { - // Ask React to render this story. - // The Promise resolves only after the commit (in the useEffect above), - // so we don't notify native until the view is actually painted. - await new Promise((resolve) => { - renderResolverRef.current = resolve; - setCurrentStoryId(story.id); - }); - - // Hand off to native — resolves when the test thread has taken the screenshot. - await StorybookRegistry.notifyStoryReady(story.id); + storiesRef.current = stories; + + if (stories.length === 0) { + StorybookRegistry.allStoriesDone(); + return; } - // Tell the test thread there are no more stories. - StorybookRegistry.allStoriesDone(); + indexRef.current = 0; + setCurrentStoryId(stories[0].id); } - runAllStories(); + init(); }, []); if (error) { From 2fba6a905309c2738143a104267e00cd6695fc93 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Mar 2026 12:28:48 -0400 Subject: [PATCH 3/4] 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 538ab4a..19b9f61 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 @@ -101,6 +101,10 @@ abstract class BaseStoryScreenshotTest { 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 85e68ad6108522621a5aed32e37ac1003c6d6a62 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Mar 2026 14:00:40 -0400 Subject: [PATCH 4/4] fix: recursively set LAYER_TYPE_SOFTWARE before Screenshot.snap ViewHelpers only sets software layer on the root view. Fabric child views retain hardware display lists which view.draw(canvas) cannot capture, producing blank (white background only) screenshots. Walk the full tree and set LAYER_TYPE_SOFTWARE on every node before snapping. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 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 19b9f61..bd8c474 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 @@ -7,6 +7,7 @@ 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 @@ -101,10 +102,9 @@ abstract class BaseStoryScreenshotTest { val screenshotName = storyId.replace("--", "_") instrumentation.runOnMainSync { - ViewHelpers.setupView(view) - .setExactWidthPx(SCREEN_WIDTH_PX) - .setExactHeightPx(SCREEN_HEIGHT_PX) - .layout() + // view.draw(canvas) can't capture children that have hardware display + // lists. Force the entire tree to software so draw() sees all content. + setLayerTypeSoftwareRecursively(view) Screenshot.snap(view).setName(screenshotName).record() } Log.d(TAG, "Screenshot captured: $screenshotName") @@ -199,6 +199,15 @@ abstract class BaseStoryScreenshotTest { } } + 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)) + } + } + } + private fun waitTwoFrames() { val instrumentation = InstrumentationRegistry.getInstrumentation() repeat(2) {