From 5465b41d3da2d5b02b22a68bedfe4967263f0f28 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 23 Mar 2026 17:18:21 -0400 Subject: [PATCH 01/11] Use loadStory to switch stories on a single surface instead of remounting Previously a new ReactSurface was created and destroyed for each story, forcing createPreparedStoryMapping() to re-run every time. On a slow CI emulator this caused awaitStoryReady() to time out, producing screenshots of the "Loading story..." state. Now a single surface is kept alive for all stories. The first story is passed as the initial prop; subsequent stories are switched by emitting the loadStory native event, which updates activeStoryName state in StoryRenderer and triggers a re-render. _idToPrepared is built once on the first render and reused for all subsequent switches. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 44 +++++++++---------- .../StorybookRegistry.kt | 10 +++++ .../src/StoryRenderer.tsx | 13 +++--- 3 files changed, 40 insertions(+), 27 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..9c57d7b 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 @@ -102,15 +102,28 @@ abstract class BaseStoryScreenshotTest { 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) + // Keep a single surface alive for all stories. The first story is passed as the + // initial prop; subsequent stories are switched via loadStory() events so that + // _idToPrepared is only built once and each switch is fast. + StorybookRegistry.prepareForNextStory() + renderStory(stories.first().toStoryName()) { view -> + for (story in stories) { + try { + if (story != stories.first()) { + StorybookRegistry.prepareForNextStory() + StorybookRegistry.loadStory(story.toStoryName()) + } + StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) + val screenshotName = story.id.replace("--", "_") + 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) + } } } @@ -125,19 +138,6 @@ abstract class BaseStoryScreenshotTest { ) } - 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) { 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..dba902d 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 @@ -25,6 +25,12 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas const val STORIES_FILE_NAME = "storybook_stories.json" @Volatile private var storyReadyLatch: CountDownLatch? = null + @Volatile private var instance: StorybookRegistry? = null + + /** Emit a loadStory event to JS, switching the active story without remounting. */ + fun loadStory(storyName: String) { + instance?.loadStory(storyName) + } /** * Call before rendering each story. Creates a fresh latch for [awaitStoryReady]. @@ -69,6 +75,10 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas } } + init { + instance = this + } + override fun getName(): String = "StorybookRegistry" /** diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index c45f540..5f99d72 100644 --- a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx +++ b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx @@ -56,15 +56,18 @@ export function registerStoriesWithNative() { * @param storyName - Format: "ComponentName/StoryName" (e.g., "MyFeature/Initial") */ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRendererProps) { + const [activeStoryName, setActiveStoryName] = useState(storyName); const [storyContent, setStoryContent] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); - useEffect (() => { + // Switch stories without remounting — native calls loadStory() to trigger a new render. + useEffect(() => { const emitter = new NativeEventEmitter(NativeModules.StorybookRegistry); const sub = emitter.addListener('loadStory', (name: string) => { - console.log('loadStory event received:', name); - }); + setLoading(true); + setActiveStoryName(name); + }); return () => sub.remove(); }, []); @@ -89,7 +92,7 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer // doesn't need to wait for createPreparedStoryMapping(). registerStoriesWithNative(); - const storyId = storyNameToId(storyName); + const storyId = storyNameToId(activeStoryName); // Lazily populate _idToPrepared — createPreparedStoryMapping() is async. if (!storybookView._idToPrepared || Object.keys(storybookView._idToPrepared).length === 0) { @@ -118,7 +121,7 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer } renderStory(); - }, [storyName]); + }, [activeStoryName]); if (loading) { return ( From ce5e65b8f1deb43f1619aba9fbac5114e412f5a4 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 23 Mar 2026 17:47:07 -0400 Subject: [PATCH 02/11] Fix loadStory events not reaching JS: use DeviceEventEmitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NativeEventEmitter(module) requires the native module to implement addListener/removeListeners — StorybookRegistry doesn't, so events were silently dropped. The native side emits via DeviceEventManagerModule, so the correct JS receiver is DeviceEventEmitter. Co-Authored-By: Claude Sonnet 4.6 --- packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index 5f99d72..5380423 100644 --- a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx +++ b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, NativeModules, NativeEventEmitter } from 'react-native'; +import { View, Text, StyleSheet, NativeModules, DeviceEventEmitter } from 'react-native'; import { storyNameToId } from './utils'; const { StorybookRegistry } = NativeModules; @@ -63,8 +63,7 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer // Switch stories without remounting — native calls loadStory() to trigger a new render. useEffect(() => { - const emitter = new NativeEventEmitter(NativeModules.StorybookRegistry); - const sub = emitter.addListener('loadStory', (name: string) => { + const sub = DeviceEventEmitter.addListener('loadStory', (name: string) => { setLoading(true); setActiveStoryName(name); }); From d9f698a5cd62b9bdcfc28d2a04d82787576664d3 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 24 Mar 2026 12:01:14 -0400 Subject: [PATCH 03/11] Sync main thread before screenshot to fix Fabric timing race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notifyStoryReady() fires in JS useEffect after React's commit, but Fabric dispatches view-tree mutations to the main thread asynchronously. Without a sync point, the test thread could screenshot the stale (first story) view before the main thread has applied the new story's mutations. runOnMainSync {} flushes all pending main-thread tasks — including Fabric's mutations — before Screenshot.snap().record() draws the view. Co-Authored-By: Claude Sonnet 4.6 --- .../rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt | 6 ++++++ 1 file changed, 6 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 1a0ac6d..91dc5c1 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 @@ -114,6 +114,12 @@ abstract class BaseStoryScreenshotTest { StorybookRegistry.loadStory(story.toStoryName()) } StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) + // Fabric dispatches view-tree mutations to the main thread asynchronously + // after React's JS-side commit. notifyStoryReady() fires in useEffect + // (post-commit, JS thread), but the main thread may not have applied those + // mutations yet. runOnMainSync flushes the main thread's pending queue so + // the screenshot captures the updated view rather than the first story. + InstrumentationRegistry.getInstrumentation().runOnMainSync { } val screenshotName = story.id.replace("--", "_") Screenshot.snap(view).setName(screenshotName).record() Log.d(TAG, "Screenshot captured: $screenshotName") From 14dcbdefcae69c0285181c52b609a32bc0434827 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 24 Mar 2026 15:29:09 -0400 Subject: [PATCH 04/11] Replace runOnMainSync with Choreographer frame wait for screenshot timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runOnMainSync{} only drains the Handler queue. Fabric dispatches mount items via postFrameCallback (Choreographer), which is separate from the Handler — so runOnMainSync can return before Fabric has applied the new story's mutations. Posting our own postFrameCallback ensures Fabric's earlier-registered callback (queued during the React commit, before useEffect fired) always runs first in the same vsync. The native view hierarchy is guaranteed to reflect the current story before Screenshot.snap().record() draws it. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 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 91dc5c1..83964f7 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,10 +4,13 @@ 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 import androidx.test.platform.app.InstrumentationRegistry +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import androidx.test.rule.GrantPermissionRule import com.facebook.react.ReactApplication import com.facebook.react.ReactRootView @@ -114,12 +117,19 @@ abstract class BaseStoryScreenshotTest { StorybookRegistry.loadStory(story.toStoryName()) } StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) - // Fabric dispatches view-tree mutations to the main thread asynchronously - // after React's JS-side commit. notifyStoryReady() fires in useEffect - // (post-commit, JS thread), but the main thread may not have applied those - // mutations yet. runOnMainSync flushes the main thread's pending queue so - // the screenshot captures the updated view rather than the first story. - InstrumentationRegistry.getInstrumentation().runOnMainSync { } + // Fabric dispatches view-tree mutations via the Choreographer + // (postFrameCallback / DISPATCH_UI), not via a plain Handler post. + // runOnMainSync{} only drains the Handler queue and can therefore + // return *before* Fabric has applied the new story's mutations. + // Posting our own postFrameCallback guarantees that Fabric's earlier- + // registered callback (queued during the React commit, before useEffect + // fired) runs first, leaving the native view hierarchy up-to-date when + // we draw the screenshot. + val frameLatch = CountDownLatch(1) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + Choreographer.getInstance().postFrameCallback { frameLatch.countDown() } + } + frameLatch.await(1000, TimeUnit.MILLISECONDS) val screenshotName = story.id.replace("--", "_") Screenshot.snap(view).setName(screenshotName).record() Log.d(TAG, "Screenshot captured: $screenshotName") From 51aeedd0961fa13e7b273cf3b6d6d5d3bf2eba6b Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 24 Mar 2026 16:45:27 -0400 Subject: [PATCH 05/11] Run Screenshot.snap().record() explicitly on the main thread The library detects when it's already on the UI thread and skips its own Handler dispatch, so there's no deadlock. Being explicit here makes it clear that the draw always happens on the main thread, consistent with the Choreographer frame wait above. Co-Authored-By: Claude Sonnet 4.6 --- .../com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 83964f7..b857040 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 @@ -131,7 +131,9 @@ abstract class BaseStoryScreenshotTest { } frameLatch.await(1000, TimeUnit.MILLISECONDS) val screenshotName = story.id.replace("--", "_") - Screenshot.snap(view).setName(screenshotName).record() + InstrumentationRegistry.getInstrumentation().runOnMainSync { + Screenshot.snap(view).setName(screenshotName).record() + } Log.d(TAG, "Screenshot captured: $screenshotName") successCount++ } catch (e: Exception) { From 95b24756e923475a6e96ea9573262545f0a7155d Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 25 Mar 2026 12:11:41 -0400 Subject: [PATCH 06/11] Remount surface per story instead of switching via loadStory events DeviceEventEmitter emission from native is unreliable in bridgeless new-arch mode (RN 0.82 + newArchEnabled=true), causing awaitStoryReady to time out and every screenshot to show the first story. Fix by mounting a fresh surface for each story with the correct storyName prop, removing the need for loadStory events entirely. Also adds try/finally to renderStory so surface cleanup always runs even if the lambda throws. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 58 +++++++++---------- 1 file changed, 28 insertions(+), 30 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 b857040..1f277c6 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 @@ -105,26 +105,20 @@ abstract class BaseStoryScreenshotTest { var failureCount = 0 val failures = mutableListOf() - // Keep a single surface alive for all stories. The first story is passed as the - // initial prop; subsequent stories are switched via loadStory() events so that - // _idToPrepared is only built once and each switch is fast. - StorybookRegistry.prepareForNextStory() - renderStory(stories.first().toStoryName()) { view -> - for (story in stories) { - try { - if (story != stories.first()) { - StorybookRegistry.prepareForNextStory() - StorybookRegistry.loadStory(story.toStoryName()) - } + // Mount a fresh surface for each story, passing the story name as the initial prop. + // This avoids relying on DeviceEventEmitter to switch stories (unreliable in + // bridgeless/new-arch mode) and ensures each story renders from a clean state. + for (story in stories) { + try { + StorybookRegistry.prepareForNextStory() + renderStory(story.toStoryName()) { view -> StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) // Fabric dispatches view-tree mutations via the Choreographer // (postFrameCallback / DISPATCH_UI), not via a plain Handler post. - // runOnMainSync{} only drains the Handler queue and can therefore - // return *before* Fabric has applied the new story's mutations. - // Posting our own postFrameCallback guarantees that Fabric's earlier- - // registered callback (queued during the React commit, before useEffect - // fired) runs first, leaving the native view hierarchy up-to-date when - // we draw the screenshot. + // Posting our own postFrameCallback after awaitStoryReady guarantees + // that Fabric's earlier-registered callback (queued during the React + // commit, before useEffect fired) runs first, leaving the native view + // hierarchy up-to-date when we draw the screenshot. val frameLatch = CountDownLatch(1) InstrumentationRegistry.getInstrumentation().runOnMainSync { Choreographer.getInstance().postFrameCallback { frameLatch.countDown() } @@ -136,12 +130,12 @@ abstract class BaseStoryScreenshotTest { } 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) } + } catch (e: Exception) { + failureCount++ + val errorMsg = "${story.title}/${story.name}: ${e.message}" + failures.add(errorMsg) + Log.e(TAG, "Failed to screenshot story: $errorMsg", e) } } @@ -216,11 +210,13 @@ abstract class BaseStoryScreenshotTest { surface.start() } - onRendered(view) - - instrumentation.runOnMainSync { - surface.stop() - wm.removeView(view) + try { + onRendered(view) + } finally { + instrumentation.runOnMainSync { + surface.stop() + wm.removeView(view) + } } } else { // Old arch: ReactRootView + ReactInstanceManager (deprecated API). @@ -242,9 +238,11 @@ abstract class BaseStoryScreenshotTest { .setExactHeightPx(SCREEN_HEIGHT_PX) .layout() - onRendered(rootView) - - instrumentation.runOnMainSync { rootView.unmountReactApplication() } + try { + onRendered(rootView) + } finally { + instrumentation.runOnMainSync { rootView.unmountReactApplication() } + } } } From 639bda66f3055c64634c01fddf231cd7ee3e001c Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 25 Mar 2026 12:33:55 -0400 Subject: [PATCH 07/11] Pass story ID directly to StoryRenderer, fixing hierarchical titles storyNameToId() only split on the first "/" so stories with hierarchical titles like "Example/Button" produced wrong IDs (e.g. "example--button" instead of "example-button--primary"), causing those stories to fail with "story not found". Fix by passing story.id (already the correct Storybook ID from the manifest) directly as the storyName prop and using it verbatim in StoryRenderer, removing the lossy title/name round-trip entirely. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 7 +++++-- .../src/StoryRenderer.tsx | 10 ++++++---- 2 files changed, 11 insertions(+), 6 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 1f277c6..d9a4208 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 @@ -105,13 +105,16 @@ abstract class BaseStoryScreenshotTest { var failureCount = 0 val failures = mutableListOf() - // Mount a fresh surface for each story, passing the story name as the initial prop. + // Mount a fresh surface for each story, passing the story ID as the initial prop. // This avoids relying on DeviceEventEmitter to switch stories (unreliable in // bridgeless/new-arch mode) and ensures each story renders from a clean state. + // We pass the ID (e.g. "example-button--primary") rather than the title/name + // path so that StoryRenderer can look it up directly in _idToPrepared without + // any string conversion that would break hierarchical titles like "Example/Button". for (story in stories) { try { StorybookRegistry.prepareForNextStory() - renderStory(story.toStoryName()) { view -> + renderStory(story.id) { view -> StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) // Fabric dispatches view-tree mutations via the Choreographer // (postFrameCallback / DISPATCH_UI), not via a plain Handler post. diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index 5380423..b625a07 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, DeviceEventEmitter } from 'react-native'; -import { storyNameToId } from './utils'; const { StorybookRegistry } = NativeModules; @@ -53,9 +52,9 @@ export function registerStoriesWithNative() { * Renders individual Storybook stories for screenshot testing. * Uses Storybook's actual rendering pipeline. * - * @param storyName - Format: "ComponentName/StoryName" (e.g., "MyFeature/Initial") + * @param storyName - Storybook story ID (e.g., "myfeature--initial") */ -export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRendererProps) { +export function StoryRenderer({ storyName = 'myfeature--initial' }: StoryRendererProps) { const [activeStoryName, setActiveStoryName] = useState(storyName); const [storyContent, setStoryContent] = useState(null); const [error, setError] = useState(null); @@ -91,7 +90,10 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer // doesn't need to wait for createPreparedStoryMapping(). registerStoriesWithNative(); - const storyId = storyNameToId(activeStoryName); + // activeStoryName IS the story ID (e.g. "example-button--primary"). + // Native passes the ID directly so hierarchical titles like "Example/Button" + // are handled correctly without any string conversion. + const storyId = activeStoryName; // Lazily populate _idToPrepared — createPreparedStoryMapping() is async. if (!storybookView._idToPrepared || Object.keys(storybookView._idToPrepared).length === 0) { From ce1fbcc0774853123da8921f68c4260a20771c02 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 25 Mar 2026 12:56:23 -0400 Subject: [PATCH 08/11] Wait for createPreparedStoryMapping in bootstrap before story loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bootstrap was tearing down as soon as the manifest file appeared, but createPreparedStoryMapping() was still running async. Stories starting right after bootstrap found _idToPrepared empty and had to wait for it themselves, non-deterministically exceeding the 5s timeout. Fix: prepareForNextStory() before bootstrap so awaitStoryReady() can block until JS calls notifyStoryReady() — which only fires after createPreparedStoryMapping() completes. _idToPrepared is then fully populated before any story surface starts. Co-Authored-By: Claude Sonnet 4.6 --- .../rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt | 6 ++++++ 1 file changed, 6 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 d9a4208..f3c4778 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 @@ -155,8 +155,14 @@ abstract class BaseStoryScreenshotTest { private fun bootstrapManifest(manifestFile: File) { Log.d(TAG, "Launching StoryRenderer to generate manifest...") + // prepareForNextStory() so awaitStoryReady() below has a latch to wait on. + // JS calls notifyStoryReady() only after createPreparedStoryMapping() finishes, + // so by the time we return here _idToPrepared is fully populated and every + // story in the loop can skip the expensive async mapping call. + StorybookRegistry.prepareForNextStory() renderStory(BOOTSTRAP_STORY_NAME) { waitForManifestFile(manifestFile) + StorybookRegistry.awaitStoryReady(getBootstrapTimeoutMs()) } Log.d(TAG, "Bootstrap complete") } From 2c83b6661f21e819254a422eea06c4d7dd065101 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 25 Mar 2026 13:18:47 -0400 Subject: [PATCH 09/11] Always warm up before story loop, not just on missing manifest The manifest persists on device between test runs, so the conditional bootstrap was skipped on re-runs and _idToPrepared was empty when the story loop started. Stories then had to await createPreparedStoryMapping themselves, non-deterministically hitting the 5s timeout. Fix: unconditionally run bootstrapManifest before the story loop so _idToPrepared is always populated and the manifest is always fresh. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 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 f3c4778..b66fb01 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 @@ -87,15 +87,21 @@ abstract class BaseStoryScreenshotTest { @Test fun screenshotAllStories() { val context = InstrumentationRegistry.getInstrumentation().targetContext - val externalDir = context.getExternalFilesDir("screenshots") + 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) - } + // Always run the warm-up surface before the story loop, even if the manifest + // already exists on disk from a previous run. This guarantees: + // 1. The manifest is fresh (reflects the current story list). + // 2. _idToPrepared is fully populated before any story's timeout starts. + // Without this, stories on the first run after a manifest exists would have + // to wait for createPreparedStoryMapping() themselves, non-deterministically + // exceeding the 5 s timeout. + Log.d(TAG, "Warming up Storybook (registering stories + building prepared map)...") + bootstrapManifest(manifestFile) + Log.d(TAG, "Warm-up complete") - val allStories = StorybookRegistry.getStoriesFromFile(externalDir!!) + val allStories = StorybookRegistry.getStoriesFromFile(externalDir) val stories = allStories.filter { shouldScreenshotStory(it) } Log.d(TAG, "Found ${allStories.size} stories, ${stories.size} after filtering") From aabfec37f13602b603590e913191cf6cbcad3132 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 26 Mar 2026 13:27:01 -0400 Subject: [PATCH 10/11] Mount surface once and drive stories via loadStory() on the main thread Instead of remounting a fresh ReactSurface for every story, keep one surface alive for the entire test run. After the bootstrap render populates the story manifest and _idToPrepared, each story is switched by emitting a loadStory event from the main thread and waiting on a CountDownLatch that notifyStoryReady() releases. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 139 +++++++----------- 1 file changed, 54 insertions(+), 85 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 b66fb01..466b141 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 @@ -31,9 +31,8 @@ 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. + * This test mounts a single surface once, bootstraps the story manifest, then + * drives each story via loadStory() events rather than remounting per story. */ abstract class BaseStoryScreenshotTest { @@ -81,96 +80,78 @@ abstract class BaseStoryScreenshotTest { /** * 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. + * + * Mounts a single ReactSurface for the entire run. The first render + * (storyName="__bootstrap__") triggers registerStoriesWithNative() and + * createPreparedStoryMapping() on the JS side. Subsequent stories are loaded + * via loadStory() events fired on the main thread, each waiting on a fresh + * CountDownLatch that notifyStoryReady() releases. */ @Test fun screenshotAllStories() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val externalDir = context.getExternalFilesDir("screenshots")!! + val instrumentation = InstrumentationRegistry.getInstrumentation() + val externalDir = instrumentation.targetContext.getExternalFilesDir("screenshots")!! val manifestFile = File(externalDir, StorybookRegistry.STORIES_FILE_NAME) - // Always run the warm-up surface before the story loop, even if the manifest - // already exists on disk from a previous run. This guarantees: - // 1. The manifest is fresh (reflects the current story list). - // 2. _idToPrepared is fully populated before any story's timeout starts. - // Without this, stories on the first run after a manifest exists would have - // to wait for createPreparedStoryMapping() themselves, non-deterministically - // exceeding the 5 s timeout. - Log.d(TAG, "Warming up Storybook (registering stories + building prepared map)...") - bootstrapManifest(manifestFile) - Log.d(TAG, "Warm-up complete") + // Prepare a latch for the bootstrap render. + StorybookRegistry.prepareForNextStory() - val allStories = StorybookRegistry.getStoriesFromFile(externalDir) - val stories = allStories.filter { shouldScreenshotStory(it) } + // Mount a single surface for the whole test. The bootstrap render will: + // 1. Call registerStoriesWithNative() → write the manifest to disk. + // 2. Call createPreparedStoryMapping() → populate _idToPrepared. + // 3. Call notifyStoryReady() (via error path — __bootstrap__ is not a real story). + renderStory(BOOTSTRAP_STORY_NAME) { view -> + waitForManifestFile(manifestFile) + StorybookRegistry.awaitStoryReady(getBootstrapTimeoutMs()) + Log.d(TAG, "Bootstrap complete, surface ready") - Log.d(TAG, "Found ${allStories.size} stories, ${stories.size} after filtering") - assertTrue("No stories found in manifest", stories.isNotEmpty()) + val allStories = StorybookRegistry.getStoriesFromFile(externalDir) + val stories = allStories.filter { shouldScreenshotStory(it) } + Log.d(TAG, "Found ${allStories.size} stories, ${stories.size} after filtering") + assertTrue("No stories found in manifest", stories.isNotEmpty()) - var successCount = 0 - var failureCount = 0 - val failures = mutableListOf() + var successCount = 0 + val failures = mutableListOf() - // Mount a fresh surface for each story, passing the story ID as the initial prop. - // This avoids relying on DeviceEventEmitter to switch stories (unreliable in - // bridgeless/new-arch mode) and ensures each story renders from a clean state. - // We pass the ID (e.g. "example-button--primary") rather than the title/name - // path so that StoryRenderer can look it up directly in _idToPrepared without - // any string conversion that would break hierarchical titles like "Example/Button". - for (story in stories) { - try { - StorybookRegistry.prepareForNextStory() - renderStory(story.id) { view -> + for (story in stories) { + try { + StorybookRegistry.prepareForNextStory() + instrumentation.runOnMainSync { + StorybookRegistry.loadStory(story.id) + } StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) - // Fabric dispatches view-tree mutations via the Choreographer - // (postFrameCallback / DISPATCH_UI), not via a plain Handler post. - // Posting our own postFrameCallback after awaitStoryReady guarantees - // that Fabric's earlier-registered callback (queued during the React - // commit, before useEffect fired) runs first, leaving the native view - // hierarchy up-to-date when we draw the screenshot. - val frameLatch = CountDownLatch(1) - InstrumentationRegistry.getInstrumentation().runOnMainSync { - Choreographer.getInstance().postFrameCallback { frameLatch.countDown() } + + // Wait two frames so Fabric's native view mutations are fully applied + // before we snap the software-layer bitmap. + repeat(2) { + val frameLatch = CountDownLatch(1) + instrumentation.runOnMainSync { + Choreographer.getInstance().postFrameCallback { frameLatch.countDown() } + } + frameLatch.await(1000, TimeUnit.MILLISECONDS) } - frameLatch.await(1000, TimeUnit.MILLISECONDS) + val screenshotName = story.id.replace("--", "_") - InstrumentationRegistry.getInstrumentation().runOnMainSync { + instrumentation.runOnMainSync { Screenshot.snap(view).setName(screenshotName).record() } Log.d(TAG, "Screenshot captured: $screenshotName") successCount++ + } catch (e: Exception) { + failures.add("${story.title}/${story.name}: ${e.message}") + Log.e(TAG, "Failed to screenshot story: ${story.id}", e) } - } catch (e: Exception) { - failureCount++ - val errorMsg = "${story.title}/${story.name}: ${e.message}" - failures.add(errorMsg) - Log.e(TAG, "Failed to screenshot story: $errorMsg", e) } - } - Log.d(TAG, "Screenshot results: $successCount passed, $failureCount failed") - if (failures.isNotEmpty()) { - Log.e(TAG, "Failed stories:\n${failures.joinToString("\n")}") - } - - assertTrue( - "Some stories failed to screenshot: ${failures.joinToString(", ")}", - failures.isEmpty() - ) - } - - private fun bootstrapManifest(manifestFile: File) { - Log.d(TAG, "Launching StoryRenderer to generate manifest...") - // prepareForNextStory() so awaitStoryReady() below has a latch to wait on. - // JS calls notifyStoryReady() only after createPreparedStoryMapping() finishes, - // so by the time we return here _idToPrepared is fully populated and every - // story in the loop can skip the expensive async mapping call. - StorybookRegistry.prepareForNextStory() - renderStory(BOOTSTRAP_STORY_NAME) { - waitForManifestFile(manifestFile) - StorybookRegistry.awaitStoryReady(getBootstrapTimeoutMs()) + Log.d(TAG, "Screenshot results: $successCount passed, ${failures.size} failed") + if (failures.isNotEmpty()) { + Log.e(TAG, "Failed stories:\n${failures.joinToString("\n")}") + } + assertTrue( + "Some stories failed to screenshot: ${failures.joinToString(", ")}", + failures.isEmpty() + ) } - Log.d(TAG, "Bootstrap complete") } /** @@ -185,10 +166,6 @@ abstract class BaseStoryScreenshotTest { 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 @@ -211,15 +188,10 @@ abstract class BaseStoryScreenshotTest { WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, PixelFormat.TRANSLUCENT ).apply { - // alpha=0 lets the compositor skip this window entirely while still - // satisfying Fabric's requirement that the surface be attached to a Window. alpha = 0f } 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() @@ -241,13 +213,10 @@ abstract class BaseStoryScreenshotTest { @Suppress("DEPRECATION") val reactInstanceManager = app.reactNativeHost.reactInstanceManager - // ReactRootView.startReactApplication() checks isOnUiThread() internally. instrumentation.runOnMainSync { rootView.startReactApplication(reactInstanceManager, getMainComponentName(), props) } - // 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) From 254d0914bed0390bb284fcb321431425ff733edf Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 26 Mar 2026 14:15:50 -0400 Subject: [PATCH 11/11] Use emitDeviceEvent instead of getJSModule for loadStory getJSModule(RCTDeviceEventEmitter) is a bridge-era API that returns null in bridgeless/new-arch mode, silently dropping the event. emitDeviceEvent is the current API used by RN's own DeviceEventManagerModule internals. Co-Authored-By: Claude Sonnet 4.6 --- .../com/rnstorybookautoscreenshots/StorybookRegistry.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 dba902d..e6f18ef 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 @@ -10,8 +10,6 @@ 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. @@ -91,9 +89,7 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas } fun loadStory(storyName: String) { - reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - ?.emit("loadStory", storyName) + reactApplicationContext.emitDeviceEvent("loadStory", storyName) }