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