From 189a4181a654eb7abae3d24c7fbd398e77558839 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 25 Feb 2026 12:43:21 -0500 Subject: [PATCH 01/15] Replace boilerplate README with library documentation Co-Authored-By: Claude Sonnet 4.6 --- README.md | 127 +++++++++++++++++++++++++++++------------------------- 1 file changed, 69 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 3e2c3f8..7e5fe66 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,108 @@ -This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). +# rn-storybook-auto-screenshots -# Getting Started +Automatically generate screenshot tests for every Storybook story in your React Native app. -> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. +## How it works -## Step 1: Start Metro +1. On first test run, the library launches your app and lets React Native register all Storybook stories with a native module (`StorybookRegistry`) +2. The native module writes a `storybook_stories.json` manifest to device storage +3. The test runner reads the manifest and launches a `StoryRendererActivity` for each story, capturing a screenshot with [screenshot-tests-for-android](https://github.com/screenshotbot/screenshot-tests-for-android) -First, you will need to run **Metro**, the JavaScript build tool for React Native. +No manual list of stories needed — add a story to Storybook and it gets tested automatically. -To start the Metro dev server, run the following command from the root of your React Native project: +## Installation ```sh -# Using npm -npm start - -# OR using Yarn -yarn start +npm install rn-storybook-auto-screenshots ``` -## Step 2: Build and run your app +## Setup -With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: +### 1. JS side — configure and register -### Android +In your app's entry point (e.g. `index.js`): -```sh -# Using npm -npm run android +```js +import { AppRegistry } from 'react-native'; +import { configure, StoryRenderer } from 'rn-storybook-auto-screenshots'; +import { view } from './.rnstorybook/storybook.requires'; -# OR using Yarn -yarn android -``` +configure(view); -### iOS +AppRegistry.registerComponent('StoryRenderer', () => StoryRenderer); +AppRegistry.registerComponent('YourApp', () => YourApp); +``` -For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). +### 2. Android — create the activity -The first time you create a new project, run the Ruby bundler to install CocoaPods itself: +In your app's `android/app/src/main/java/…` directory: -```sh -bundle install +```kotlin +class StoryRendererActivity : BaseStoryRendererActivity() ``` -Then, and every time you update your native dependencies, run: +Register it in `AndroidManifest.xml`: -```sh -bundle exec pod install +```xml + ``` -For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). - -```sh -# Using npm -npm run ios +Add the native module to your React Native package list: -# OR using Yarn -yarn ios +```kotlin +// In your MainApplication +override fun getPackages() = listOf( + RNStorybookAutoScreenshotsPackage(), + // ... your other packages +) ``` -If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. - -This is one way to run your app — you can also build it directly from Android Studio or Xcode. +### 3. Android — create the test -## Step 3: Modify your app +In `android/app/src/androidTest/java/…`: -Now that you have successfully run the app, let's make changes! +```kotlin +@RunWith(AndroidJUnit4::class) +class StoryScreenshotTest : BaseStoryScreenshotTest() { + override fun getStoryRendererActivityClass() = StoryRendererActivity::class.java +} +``` -Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). +### 4. Run the tests -When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: +```sh +./gradlew screenshotTests +``` -- **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS). -- **iOS**: Press R in iOS Simulator. +## Customization -## Congratulations! :tada: +Override methods in `BaseStoryScreenshotTest` to customize behavior: -You've successfully run and modified your React Native App. :partying_face: +```kotlin +class StoryScreenshotTest : BaseStoryScreenshotTest() { + override fun getStoryRendererActivityClass() = StoryRendererActivity::class.java -### Now what? + // Skip stories you don't want to screenshot + override fun shouldScreenshotStory(storyInfo: StoryInfo): Boolean { + return storyInfo.title != "Internal" + } -- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). -- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). + // Adjust timeout if stories are slow to load (default: 5000ms) + override fun getLoadTimeoutMs() = 8000L +} +``` -# Troubleshooting +## JS API -If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. +```ts +import { configure, StoryRenderer, getAllStories, getAllStoryIds, storyNameToId } from 'rn-storybook-auto-screenshots'; -# Learn More +configure(view); // Must be called once with your Storybook view instance +getAllStories(); // Returns [{ id, title, name }] +getAllStoryIds(); // Returns string[] +storyNameToId('Foo/Bar'); // Converts "ComponentName/StoryName" to a Storybook ID +``` -To learn more about React Native, take a look at the following resources: +## License -- [React Native Website](https://reactnative.dev) - learn more about React Native. -- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment. -- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. -- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. -- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. +Apache-2.0 From 77434a03d65dfa4366f9964af9c5d25cbb0594b8 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Mar 2026 15:26:38 -0400 Subject: [PATCH 02/15] Move loadStory to instance method, remove companion object hack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadStory doesn't need to be static — it's called from within the module instance where reactApplicationContext is already available. Removes the reactCtx field and init block that were only needed to bridge the instance context into the companion object. Co-Authored-By: Claude Sonnet 4.6 --- .../com/rnstorybookautoscreenshots/StorybookRegistry.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 0215680..75b20a5 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 @@ -76,7 +76,11 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas fun notifyStoryReady() { storyReadyLatch?.countDown() } - + fun loadStory(storyName: String) { + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + ?.emit("loadStory", storyName) + } /** * Called from JS to register the list of available stories. * Writes to external files directory for test access. From 61e330fd075c68cd395a2aaa918f18187515f9d9 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 10 Mar 2026 14:17:52 -0400 Subject: [PATCH 03/15] Add loadStory event emitter to StorybookRegistry Adds a native-to-JS event emitter so the test runner can signal which story to render without relaunching the activity. Co-Authored-By: Claude Sonnet 4.6 --- .../rnstorybookautoscreenshots/StorybookRegistry.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 75b20a5..6a5a64c 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,11 +10,13 @@ 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 that receives the story list from Storybook JS side. * Stories are written to a file that screenshot tests can read. */ + class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { companion object { @@ -22,6 +24,12 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas const val STORIES_FILE_NAME = "storybook_stories.json" @Volatile private var storyReadyLatch: CountDownLatch? = null + @Volatile private var reactCtx: ReactApplicationContext? = null + + fun loadStory(storyName: String) { + reactCtx?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + ?.emit("loadStory", storyName) + } /** * Call before launching a story activity. Creates a fresh latch to wait on. @@ -65,6 +73,10 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas } } } + + init { + reactCtx = reactContext + } override fun getName(): String = "StorybookRegistry" From 8879f628fd2343631c4527fcd0134ced99ea02ed Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 10 Mar 2026 15:52:55 -0400 Subject: [PATCH 04/15] Listen for loadStory native event in StoryRenderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a NativeEventEmitter subscription to the loadStory event emitted by StorybookRegistry. Currently just logs the event — wiring up re-rendering is the next step. Co-Authored-By: Claude Sonnet 4.6 --- .../src/StoryRenderer.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index d322044..36f51fc 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 } from 'react-native'; +import { View, Text, StyleSheet, NativeModules, NativeEventEmitter } from 'react-native'; import { storyNameToId } from './utils'; const { StorybookRegistry } = NativeModules; @@ -61,6 +61,14 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer 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 when the story has finished rendering (or errored). // This runs after React commits the update, so the native views are up to date. useEffect(() => { From 9e4c5db4ee20f6de8cd26a572134652d94d1cf6d Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 12 Mar 2026 15:51:31 -0400 Subject: [PATCH 05/15] Move loadStory event emitter to BaseStoryRendererActivity Instead of storing ReactApplicationContext statically in StorybookRegistry companion object, expose loadStory() on BaseStoryRendererActivity which retrieves the module via the React instance manager. This removes the init block and reactCtx static field entirely. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryRendererActivity.kt | 6 ++++++ .../rnstorybookautoscreenshots/StorybookRegistry.kt | 10 ---------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt index 5cb13b4..eba76bb 100644 --- a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt +++ b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt @@ -32,6 +32,12 @@ open class BaseStoryRendererActivity : ReactActivity() { /** * Returns the instance of the ReactActivityDelegate with custom launch options. */ + fun loadStory(storyName: String) { + reactInstanceManager?.currentReactContext + ?.getNativeModule(StorybookRegistry::class.java) + ?.loadStory(storyName) + } + override fun createReactActivityDelegate(): ReactActivityDelegate { return object : DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) { override fun getLaunchOptions(): Bundle? { 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 6a5a64c..27c1fe3 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 @@ -24,12 +24,6 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas const val STORIES_FILE_NAME = "storybook_stories.json" @Volatile private var storyReadyLatch: CountDownLatch? = null - @Volatile private var reactCtx: ReactApplicationContext? = null - - fun loadStory(storyName: String) { - reactCtx?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - ?.emit("loadStory", storyName) - } /** * Call before launching a story activity. Creates a fresh latch to wait on. @@ -73,10 +67,6 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas } } } - - init { - reactCtx = reactContext - } override fun getName(): String = "StorybookRegistry" From 2f51ef516be0cc1d441a3d219ea9d5d41b6a5e10 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 12 Mar 2026 16:52:42 -0400 Subject: [PATCH 06/15] Add loadStory instance method to StorybookRegistry Required by BaseStoryRendererActivity.loadStory() which retrieves the module via ReactContext.getNativeModule(). Co-Authored-By: Claude Sonnet 4.6 --- .../com/rnstorybookautoscreenshots/StorybookRegistry.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 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 27c1fe3..26f7f81 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 @@ -78,11 +78,14 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas fun notifyStoryReady() { storyReadyLatch?.countDown() } + fun loadStory(storyName: String) { reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - ?.emit("loadStory", storyName) - } + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + ?.emit("loadStory", storyName) + } + + /** * Called from JS to register the list of available stories. * Writes to external files directory for test access. From 2a0bc85e54ef91cc77d39d18bf6ae777dc393a14 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 12 Mar 2026 15:01:09 -0400 Subject: [PATCH 07/15] Refactor BaseStoryScreenshotTest to use single activity Instead of launching a new ActivityScenario per story, launch once and loop through stories using StorybookRegistry.loadStory() events. ActivityScenario is now opened and closed once per test run. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 97 ++++++------------- 1 file changed, 30 insertions(+), 67 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 7d20c23..c4e74dc 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 @@ -85,36 +85,56 @@ abstract class BaseStoryScreenshotTest { val externalDir = context.getExternalFilesDir("screenshots") val manifestFile = File(externalDir, StorybookRegistry.STORIES_FILE_NAME) - // Bootstrap manifest if it doesn't exist + val intent = Intent( + ApplicationProvider.getApplicationContext(), + getStoryRendererActivityClass() + ).apply { + putExtra(BaseStoryRendererActivity.EXTRA_STORY_NAME, BOOTSTRAP_STORY_NAME) + } + + // Launch once — RN initializes and registers all stories + StorybookRegistry.prepareForNextStory() + val scenario = ActivityScenario.launch(intent) + StorybookRegistry.awaitStoryReady(getBootstrapTimeoutMs()) + if (!manifestFile.exists()) { - Log.d(TAG, "Manifest not found, bootstrapping...") - bootstrapManifest(manifestFile) + waitForManifestFile(manifestFile) } 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() for (story in stories) { try { - screenshotStory(story) - successCount++ + val storyName = story.toStoryName() + Log.d(TAG, "Screenshotting: $storyName (id: ${story.id})") + + StorybookRegistry.prepareForNextStory() + StorybookRegistry.loadStory(storyName) + StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) + + scenario.onActivity { activity -> + val screenshotName = story.id.replace("--", "_") + Screenshot.snap(activity.window.decorView.rootView) + .setName(screenshotName) + .record() + Log.d(TAG, "Screenshot captured: $screenshotName") + } } 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") + scenario.close() + + Log.d(TAG, "Screenshot results: ${stories.size - failures.size} passed, ${failures.size} failed") if (failures.isNotEmpty()) { Log.e(TAG, "Failed stories:\n${failures.joinToString("\n")}") @@ -126,63 +146,6 @@ abstract class BaseStoryScreenshotTest { ) } - private fun screenshotStory(storyInfo: StoryInfo) { - val storyName = storyInfo.toStoryName() - Log.d(TAG, "Screenshotting: $storyName (id: ${storyInfo.id})") - - val intent = Intent( - ApplicationProvider.getApplicationContext(), - getStoryRendererActivityClass() - ).apply { - putExtra(BaseStoryRendererActivity.EXTRA_STORY_NAME, storyName) - } - - StorybookRegistry.prepareForNextStory() - val scenario = ActivityScenario.launch(intent) - - // Wait for JS to signal the story has finished rendering, up to the timeout. - StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) - - scenario.onActivity { activity -> - val rootView = activity.window.decorView.rootView - - // Use story ID as screenshot name (replace -- with _ for filesystem compatibility) - val screenshotName = storyInfo.id.replace("--", "_") - - // Capture screenshot using screenshot-tests-for-android - // In record mode: saves baseline images - // In verify mode: compares against baselines - Screenshot.snap(rootView) - .setName(screenshotName) - .record() - - Log.d(TAG, "Screenshot captured: $screenshotName") - } - - scenario.close() - } - - /** - * Bootstraps the story manifest by launching StoryRendererActivity. - * This allows React Native to initialize and register all stories. - */ - private fun bootstrapManifest(manifestFile: File) { - Log.d(TAG, "Launching StoryRenderer to generate manifest...") - - val intent = Intent( - ApplicationProvider.getApplicationContext(), - getStoryRendererActivityClass() - ).apply { - putExtra(BaseStoryRendererActivity.EXTRA_STORY_NAME, BOOTSTRAP_STORY_NAME) - } - - val scenario = ActivityScenario.launch(intent) - waitForManifestFile(manifestFile) - scenario.close() - - Log.d(TAG, "Bootstrap complete") - } - /** * Polls for the manifest file until it appears or the timeout elapses. * The file is written by JS as soon as RN has loaded and registered all stories, From a9cce63d6556b1e8842cbf750ae8945790f94f28 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 12 Mar 2026 15:53:48 -0400 Subject: [PATCH 08/15] Call loadStory via activity instead of StorybookRegistry companion Uses scenario.onActivity { it.loadStory(storyName) } to trigger story switching, removing the need for a static loadStory on StorybookRegistry. Co-Authored-By: Claude Sonnet 4.6 --- .../com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt | 2 +- 1 file changed, 1 insertion(+), 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 c4e74dc..5144f1e 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 @@ -115,7 +115,7 @@ abstract class BaseStoryScreenshotTest { Log.d(TAG, "Screenshotting: $storyName (id: ${story.id})") StorybookRegistry.prepareForNextStory() - StorybookRegistry.loadStory(storyName) + scenario.onActivity { activity -> activity.loadStory(storyName) } StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) scenario.onActivity { activity -> From 79444919d0108e8ce51b02c0621fb4b405081931 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 12 Mar 2026 17:35:43 -0400 Subject: [PATCH 09/15] Trigger CI From e8e1cc7c95b6b0b8272eef67b2740cab1bcf3f59 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 13 Mar 2026 12:13:13 -0400 Subject: [PATCH 10/15] Fix loadStory on new arch by using ReactHost instead of ReactInstanceManager reactInstanceManager throws IllegalStateException on new arch because there is no ReactNativeHost. Use ReactApplication.reactHost to get the current ReactContext instead. Co-Authored-By: Claude Sonnet 4.6 --- .../rnstorybookautoscreenshots/BaseStoryRendererActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt index eba76bb..a98afa3 100644 --- a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt +++ b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt @@ -3,6 +3,7 @@ package com.rnstorybookautoscreenshots import android.os.Bundle import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate +import com.facebook.react.ReactApplication import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultReactActivityDelegate @@ -33,7 +34,7 @@ open class BaseStoryRendererActivity : ReactActivity() { * Returns the instance of the ReactActivityDelegate with custom launch options. */ fun loadStory(storyName: String) { - reactInstanceManager?.currentReactContext + (application as? ReactApplication)?.reactHost?.currentReactContext ?.getNativeModule(StorybookRegistry::class.java) ?.loadStory(storyName) } From 9bd15fb7724a23ba4416aecabd25ef1f11803b3f Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 13 Mar 2026 12:56:37 -0400 Subject: [PATCH 11/15] Fix StoryRenderer to handle bootstrap story and respond to loadStory events Two bugs: - storyNameToId crashed on '__bootstrap__' (no '/') with TypeError on name.toLowerCase(). Bootstrap story only needs to register stories, not render anything, so bail out early when storyName has no '/'. - loadStory event listener only logged; it never updated the displayed story. Add currentStoryName state, update it on the event, and reset loading so renderStory re-runs and notifyStoryReady fires. Co-Authored-By: Claude Sonnet 4.6 --- .../src/StoryRenderer.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index 36f51fc..6e93313 100644 --- a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx +++ b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx @@ -57,15 +57,17 @@ export function registerStoriesWithNative() { * @param storyName - Format: "ComponentName/StoryName" (e.g., "MyFeature/Initial") */ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRendererProps) { + const [currentStoryName, setCurrentStoryName] = useState(storyName); const [storyContent, setStoryContent] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); - useEffect (() => { + useEffect(() => { const emitter = new NativeEventEmitter(NativeModules.StorybookRegistry); const sub = emitter.addListener('loadStory', (name: string) => { - console.log('loadStory event received:', name); - }); + setCurrentStoryName(name); + setLoading(true); + }); return () => sub.remove(); }, []); @@ -91,7 +93,14 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer // doesn't need to wait for createPreparedStoryMapping(). registerStoriesWithNative(); - const storyId = storyNameToId(storyName); + // Bootstrap story has no '/' — its only purpose is to trigger story + // registration above. Nothing to render. + if (!currentStoryName.includes('/')) { + setLoading(false); + return; + } + + const storyId = storyNameToId(currentStoryName); // Wait for Storybook to be ready and prepare story mappings if (!storybookView._idToPrepared || Object.keys(storybookView._idToPrepared).length === 0) { @@ -102,7 +111,7 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer if (!preparedStory) { const availableStories = Object.keys(storybookView._idToPrepared || {}).join(', '); - setError(`Story "${storyId}" not found. Available: ${availableStories}`); + setError(`Story "${currentStoryName}" (id: ${storyId}) not found. Available: ${availableStories}`); setLoading(false); return; } @@ -123,7 +132,7 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer } renderStory(); - }, [storyName]); + }, [currentStoryName]); if (loading) { return ( From 94f4719952aebfeda8859400afaee73eb2728a46 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 13 Mar 2026 13:45:00 -0400 Subject: [PATCH 12/15] Delay notifyStoryReady until after next frame to avoid blank screenshots notifyStoryReady() was called synchronously in a useEffect, which runs after React commits on the JS thread but before native view mutations are flushed to the main thread. Wrapping in requestAnimationFrame ensures the frame is painted before the screenshot is taken. Co-Authored-By: Claude Sonnet 4.6 --- .../rn-storybook-auto-screenshots/src/StoryRenderer.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index 6e93313..2e5ab89 100644 --- a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx +++ b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx @@ -72,10 +72,13 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer }, []); // Notify native when the story has finished rendering (or errored). - // This runs after React commits the update, so the native views are up to date. + // requestAnimationFrame defers until after the next frame is painted, + // ensuring native view mutations are flushed before the screenshot is taken. useEffect(() => { if (!loading) { - StorybookRegistry.notifyStoryReady(); + requestAnimationFrame(() => { + StorybookRegistry.notifyStoryReady(); + }); } }, [loading]); From 0ec669b817413c43cdbc3e291a39e96a8420962b Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 13 Mar 2026 14:08:28 -0400 Subject: [PATCH 13/15] Trigger CI From 7eb21f109c597b9a2b7de8bf75cd8b08bef147f0 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 13 Mar 2026 14:42:35 -0400 Subject: [PATCH 14/15] Wait for UI thread idle before taking screenshot After awaitStoryReady(), React Native's native view mutations may still be pending on the UI thread. waitForIdleSync() ensures the UI thread has flushed all pending operations before the screenshot is taken. Co-Authored-By: Claude Sonnet 4.6 --- .../com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt | 2 ++ 1 file changed, 2 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 5144f1e..941fa5c 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 @@ -5,6 +5,7 @@ import android.content.Intent import android.util.Log import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.facebook.testing.screenshot.Screenshot import org.junit.Assert.assertTrue @@ -117,6 +118,7 @@ abstract class BaseStoryScreenshotTest { StorybookRegistry.prepareForNextStory() scenario.onActivity { activity -> activity.loadStory(storyName) } StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) + InstrumentationRegistry.getInstrumentation().waitForIdleSync() scenario.onActivity { activity -> val screenshotName = story.id.replace("--", "_") From fdc2d8e8f5beee07734799cd3d9446aec55f6fa1 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 13 Mar 2026 15:51:22 -0400 Subject: [PATCH 15/15] Wait for OnDrawListener before screenshotting to avoid blank frames waitForIdleSync() drains the message queue but view drawing is driven by VSYNC via Choreographer, not the queue. Screenshots were capturing a blank frame because drawing hadn't happened yet. waitForNextDraw() uses OnDrawListener to block until the next frame is actually painted. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 28 +++++++++++++++++++ 1 file changed, 28 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 941fa5c..8e75b52 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,6 +3,7 @@ package com.rnstorybookautoscreenshots import android.Manifest import android.content.Intent import android.util.Log +import android.view.ViewTreeObserver import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.platform.app.InstrumentationRegistry @@ -12,6 +13,8 @@ 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. @@ -119,6 +122,7 @@ abstract class BaseStoryScreenshotTest { scenario.onActivity { activity -> activity.loadStory(storyName) } StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() + waitForNextDraw(scenario) scenario.onActivity { activity -> val screenshotName = story.id.replace("--", "_") @@ -148,6 +152,30 @@ abstract class BaseStoryScreenshotTest { ) } + /** + * Waits for the next draw pass on the decor view. + * + * waitForIdleSync() drains the main thread message queue but view drawing + * is triggered by VSYNC via Choreographer, not the message queue. This + * method waits for an OnDrawListener callback, which fires after the next + * frame is actually drawn — ensuring the screenshot captures painted content. + */ + private fun waitForNextDraw(scenario: ActivityScenario) { + val latch = CountDownLatch(1) + scenario.onActivity { activity -> + val decorView = activity.window.decorView + decorView.viewTreeObserver.addOnDrawListener(object : ViewTreeObserver.OnDrawListener { + override fun onDraw() { + latch.countDown() + decorView.post { + decorView.viewTreeObserver.removeOnDrawListener(this) + } + } + }) + } + latch.await(2000, TimeUnit.MILLISECONDS) + } + /** * Polls for the manifest file until it appears or the timeout elapses. * The file is written by JS as soon as RN has loaded and registered all stories,