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';