From e6b38095f58da97ee1e38f4383d710ffcb663f60 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 27 Mar 2026 12:37:50 -0400 Subject: [PATCH 1/5] =?UTF-8?q?experiment:=20blocking=20sync=20pull=20?= =?UTF-8?q?=E2=80=94=20JS=20calls=20awaitNextStory()=20to=20get=20stories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of native emitting loadStory events, JS blocks itself via isBlockingSynchronousMethod awaitNextStory(). The test thread pushes story IDs into a LinkedBlockingQueue; JS unblocks, renders, calls notifyStoryReady(), then blocks again. One surface, no event emitter. NOTE: isBlockingSynchronousMethod is deprecated in new arch — this branch exists to test whether the approach works via the interop layer. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 180 ++++++++++-------- .../StorybookRegistry.kt | 50 ++++- .../src/StoryRenderer.tsx | 110 +++++------ 3 files changed, 193 insertions(+), 147 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..28cd9b7 100644 --- a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt +++ b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt @@ -4,6 +4,7 @@ import android.Manifest import android.graphics.PixelFormat import android.os.Bundle import android.util.Log +import android.view.Choreographer import android.view.ContextThemeWrapper import android.view.View import android.view.WindowManager @@ -17,6 +18,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. @@ -28,9 +31,18 @@ import java.io.File * class StoryScreenshotTest : BaseStoryScreenshotTest() * ``` * - * This test automatically bootstraps the story manifest if it doesn't exist, - * then creates a screenshot for each story. No manual test methods needed - - * just add stories to Storybook and they get tested automatically. + * Experiment: sync-story-blocking + * -------------------------------- + * Instead of emitting loadStory events, the test thread pushes story IDs into a + * LinkedBlockingQueue. JS pulls them via the blocking synchronous awaitNextStory() + * call, which blocks the JS thread while native takes the screenshot. This inverts + * the control flow: JS pulls from native rather than native pushing to JS. + * + * A single React surface is mounted for the entire run. The JS component loops: + * awaitNextStory() → render → notifyStoryReady() → awaitNextStory() → … + * + * The test thread drives the loop by pushing story IDs, waiting for notifyStoryReady, + * taking a screenshot, then pushing the next ID. A null push signals JS to stop. */ abstract class BaseStoryScreenshotTest { @@ -38,7 +50,7 @@ abstract class BaseStoryScreenshotTest { 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 BOOTSTRAP_STORY_ID = "__bootstrap__" private const val SCREEN_WIDTH_PX = 1080 private const val SCREEN_HEIGHT_PX = 1920 @@ -78,90 +90,83 @@ 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 React surface for the entire run. JS blocks itself via + * awaitNextStory() between renders; the test thread drives the sequence by + * pushing story IDs and waiting for notifyStoryReady() after each render. */ @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) - if (!manifestFile.exists()) { - Log.d(TAG, "Manifest not found, bootstrapping...") - bootstrapManifest(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() + // Push the bootstrap marker before mounting so JS unblocks immediately on mount. + StorybookRegistry.prepareForNextStory() + StorybookRegistry.pushStory(BOOTSTRAP_STORY_ID) - for (story in stories) { - try { - screenshotStory(story) - successCount++ - } catch (e: Exception) { - failureCount++ - val errorMsg = "${story.title}/${story.name}: ${e.message}" - failures.add(errorMsg) - Log.e(TAG, "Failed to screenshot story: $errorMsg", e) + mountSurface { view -> + // Wait for JS to register stories and write the manifest. + waitForManifestFile(manifestFile) + StorybookRegistry.awaitStoryReady(getBootstrapTimeoutMs()) + Log.d(TAG, "Bootstrap complete") + + 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()) + + val failures = mutableListOf() + + for (story in stories) { + try { + // Push the next story ID — JS unblocks from awaitNextStory() and renders it. + StorybookRegistry.prepareForNextStory() + StorybookRegistry.pushStory(story.id) + + // Wait for JS to signal the story is rendered. + StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) + + // Two frames so Fabric's native view mutations are fully applied. + waitTwoFrames() + + val screenshotName = story.id.replace("--", "_") + instrumentation.runOnMainSync { + Screenshot.snap(view).setName(screenshotName).record() + } + Log.d(TAG, "Screenshot captured: $screenshotName") + } catch (e: Exception) { + failures.add("${story.title}/${story.name}: ${e.message}") + Log.e(TAG, "Failed to screenshot story: ${story.id}", 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 screenshotStory(storyInfo: StoryInfo) { - val storyName = storyInfo.toStoryName() - Log.d(TAG, "Screenshotting: $storyName (id: ${storyInfo.id})") + // Signal JS that there are no more stories. + StorybookRegistry.pushStory(null) - 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, "Screenshot results: ${stories.size - failures.size} 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") } /** - * Renders the given story name into a view, calls [onRendered] with that view, - * then tears down. Handles both old arch (ReactRootView) and new arch (ReactSurface). + * Mounts a single React surface for the whole test run, calls [onMounted] with + * the view, then tears down. Handles both old arch (ReactRootView) and new arch + * (ReactSurface). No storyName prop is passed — JS drives itself via awaitNextStory(). */ - private fun renderStory(storyName: String, onRendered: (view: View) -> Unit) { + private fun mountSurface(onMounted: (view: View) -> Unit) { val instrumentation = InstrumentationRegistry.getInstrumentation() val app = instrumentation.targetContext.applicationContext as ReactApplication - val props = Bundle().apply { putString("storyName", storyName) } val reactHost = app.reactHost if (reactHost != null) { // New arch (Fabric/bridgeless): ReactHost + ReactSurface. - // Fabric won't commit its render tree until the surface's host view is parented - // to a real Window. Test processes don't have an Activity window, so we attach - // via WindowManager using TYPE_APPLICATION_OVERLAY (requires SYSTEM_ALERT_WINDOW). - // Wrap with the app theme so AppCompat widgets (e.g. Switch) resolve styled attrs. val context = ContextThemeWrapper( instrumentation.targetContext, instrumentation.targetContext.applicationInfo.theme @@ -169,7 +174,7 @@ abstract class BaseStoryScreenshotTest { val surface = reactHost.createSurface( context, getMainComponentName(), - props + Bundle() ) val view = surface.view @@ -186,19 +191,18 @@ abstract class BaseStoryScreenshotTest { ) instrumentation.runOnMainSync { - // Force software rendering so Screenshot.snap() can capture via draw(canvas). - // WindowManager views are hardware-accelerated by default; GPU content is - // invisible to a software canvas. view.setLayerType(View.LAYER_TYPE_SOFTWARE, null) wm.addView(view, params) surface.start() } - onRendered(view) - - instrumentation.runOnMainSync { - surface.stop() - wm.removeView(view) + try { + onMounted(view) + } finally { + instrumentation.runOnMainSync { + surface.stop() + wm.removeView(view) + } } } else { // Old arch: ReactRootView + ReactInstanceManager (deprecated API). @@ -208,21 +212,31 @@ abstract class BaseStoryScreenshotTest { @Suppress("DEPRECATION") val reactInstanceManager = app.reactNativeHost.reactInstanceManager - // ReactRootView.startReactApplication() checks isOnUiThread() internally. instrumentation.runOnMainSync { - rootView.startReactApplication(reactInstanceManager, getMainComponentName(), props) + rootView.startReactApplication(reactInstanceManager, getMainComponentName(), Bundle()) } - // setupView().layout() calls measure()+layout() at the fixed dimensions, which - // triggers onMeasure() → attachToReactInstanceManager() on the ReactRootView. ViewHelpers.setupView(rootView) .setExactWidthPx(SCREEN_WIDTH_PX) .setExactHeightPx(SCREEN_HEIGHT_PX) .layout() - onRendered(rootView) + try { + onMounted(rootView) + } finally { + instrumentation.runOnMainSync { rootView.unmountReactApplication() } + } + } + } - instrumentation.runOnMainSync { rootView.unmountReactApplication() } + private fun waitTwoFrames() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + repeat(2) { + val latch = CountDownLatch(1) + instrumentation.runOnMainSync { + Choreographer.getInstance().postFrameCallback { latch.countDown() } + } + latch.await(1000, TimeUnit.MILLISECONDS) } } diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/StorybookRegistry.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/StorybookRegistry.kt index 1dfb0c9..4c45cf1 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 @@ -9,23 +9,44 @@ import org.json.JSONArray import org.json.JSONObject import java.io.File import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit -import com.facebook.react.modules.core.DeviceEventManagerModule /** - * Native module with two responsibilities: + * Native module with three responsibilities: * - Receives the story list from JS and writes it to disk for test discovery. + * - Provides awaitNextStory(), a blocking synchronous JSI call that lets JS + * pull the next story ID from the test runner rather than receiving events. * - Synchronises the test thread with JS rendering via a CountDownLatch. + * + * Communication flow: + * Test thread → pushStory(id) → storyQueue + * JS thread ← awaitNextStory() ← storyQueue (blocks JS thread) + * JS thread → notifyStoryReady() + * Test thread ← awaitStoryReady() ← storyReadyLatch */ - class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { companion object { private const val TAG = "StorybookRegistry" const val STORIES_FILE_NAME = "storybook_stories.json" + // Capacity 1 so pushStory() blocks until JS has consumed the current entry, + // giving natural back-pressure between the test runner and JS. + private val storyQueue = LinkedBlockingQueue(1) + @Volatile private var storyReadyLatch: CountDownLatch? = null + /** + * Called by the test thread to enqueue the next story for JS to render. + * Pass null to signal that all stories are done. + * + * Blocks until JS has consumed the previous entry (queue capacity = 1). + */ + fun pushStory(storyId: String?) { + storyQueue.put(storyId) + } + /** * Call before rendering each story. Creates a fresh latch for [awaitStoryReady]. */ @@ -71,21 +92,30 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas override fun getName(): String = "StorybookRegistry" + /** + * Blocking synchronous JSI call — blocks the JS thread until the test runner + * pushes the next story ID via [pushStory]. + * + * Returns the story ID string, or null when the test runner signals done. + * + * NOTE: isBlockingSynchronousMethod is deprecated in the new architecture. + * This is an experimental branch to evaluate whether synchronous pulling + * is cleaner than the event-based push model. + */ + @ReactMethod(isBlockingSynchronousMethod = true) + fun awaitNextStory(): String? { + return storyQueue.poll(30, TimeUnit.SECONDS) + } + /** * Called from JS when a story has finished rendering (or errored). - * Releases the latch that screenshotStory() is waiting on. + * Releases the latch that the test thread is waiting on. */ @ReactMethod 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. diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index c45f540..aa55fa9 100644 --- a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx +++ b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, NativeModules, NativeEventEmitter } from 'react-native'; -import { storyNameToId } from './utils'; +import { View, Text, StyleSheet, NativeModules } from 'react-native'; const { StorybookRegistry } = NativeModules; @@ -19,10 +18,6 @@ export function configure(view: any) { storybookView = view; } -type StoryRendererProps = { - storyName?: string; -}; - /** * Register all available stories with the native module. * This allows the Android test to discover stories automatically. @@ -50,83 +45,90 @@ export function registerStoriesWithNative() { } /** - * Renders individual Storybook stories for screenshot testing. - * Uses Storybook's actual rendering pipeline. + * Renders Storybook stories for screenshot testing. + * + * Instead of receiving a story name via prop or event, this component drives + * itself by calling the synchronous native method awaitNextStory(), which + * blocks the JS thread until the test runner enqueues the next story ID. * - * @param storyName - Format: "ComponentName/StoryName" (e.g., "MyFeature/Initial") + * Flow: + * 1. Mount → register stories → call awaitNextStory() (blocks JS thread) + * 2. Test thread pushes story ID into the queue → JS unblocks + * 3. React re-renders the story + * 4. After commit: notifyStoryReady() releases native latch, then + * awaitNextStory() blocks again for the next story + * 5. When native pushes null, the loop ends */ -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. + // Bootstrap: register stories then block waiting for the first story ID. useEffect(() => { - if (!loading) { - StorybookRegistry.notifyStoryReady(); + if (!storybookView) { + setError('Storybook not configured. Call configure(view) first.'); + return; } - }, [loading]); - useEffect(() => { - async function renderStory() { - try { - if (!storybookView) { - setError('Storybook not configured. Call configure(view) first.'); - setLoading(false); - return; - } + registerStoriesWithNative(); - // 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(); + // awaitNextStory() is a blocking synchronous JSI call — it returns only + // when the test thread pushes a story ID (or null to signal done). + const firstId: string | null = StorybookRegistry.awaitNextStory(); + if (firstId !== null) { + setCurrentStoryId(firstId); + } + }, []); - const storyId = storyNameToId(storyName); + // Render the current story whenever currentStoryId changes. + useEffect(() => { + if (currentStoryId === null) return; + async function renderStory() { + try { // Lazily populate _idToPrepared — createPreparedStoryMapping() is async. if (!storybookView._idToPrepared || Object.keys(storybookView._idToPrepared).length === 0) { await storybookView.createPreparedStoryMapping(); } - const preparedStory = storybookView._idToPrepared[storyId]; - + const preparedStory = storybookView._idToPrepared[currentStoryId!]; if (!preparedStory) { - const availableStories = Object.keys(storybookView._idToPrepared || {}).join(', '); - setError(`Story "${storyId}" not found. Available: ${availableStories}`); - setLoading(false); + const available = Object.keys(storybookView._idToPrepared || {}).join(', '); + setError(`Story "${currentStoryId}" not found. Available: ${available}`); + setStoryContent(null); return; } const storyContext = storybookView._preview.getStoryContext(preparedStory); const { unboundStoryFn: StoryComponent } = preparedStory; - const rendered = ; - - setStoryContent(rendered); - setLoading(false); + setError(null); + setStoryContent(); } catch (e) { setError(`Error rendering story: ${e}`); - setLoading(false); + setStoryContent(null); } } renderStory(); - }, [storyName]); + }, [currentStoryId]); - if (loading) { - return ( - - Loading story... - - ); - } + // After each story commit: signal native, then block waiting for the next story. + // This effect fires once storyContent or error has settled for the current story. + useEffect(() => { + if (currentStoryId === null) return; + + // Signal native that the current story is fully rendered. + StorybookRegistry.notifyStoryReady(); + + // Block the JS thread until the test runner pushes the next story ID. + // While blocked, native takes the screenshot and advances its loop. + const nextId: string | null = StorybookRegistry.awaitNextStory(); + if (nextId !== null) { + setCurrentStoryId(nextId); + } + // else: null means all stories are done — we stop here. + }, [storyContent, error]); if (error) { return ( From 480b77957355b3939e478f454936224bb55727f3 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 27 Mar 2026 15:55:21 -0400 Subject: [PATCH 2/5] fix: replace isBlockingSynchronousMethod with Promise + background thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isBlockingSynchronousMethod is silently ignored in new arch bridgeless mode — the call returns undefined immediately, causing an infinite loop. Fix: awaitNextStory() is now a regular @ReactMethod(promise) that blocks on a background thread. JS uses await instead of a synchronous call. The LinkedBlockingQueue and push/await handshake are unchanged; only the bridge mechanism changes. Also apply buildPreparedStories() fix from experiment/js-driven-story-loop: access _preview.storyStoreValue directly to skip _preview.ready(), which never resolves when the Storybook UI isn't rendered. Co-Authored-By: Claude Sonnet 4.6 --- .../StorybookRegistry.kt | 27 ++--- .../src/StoryRenderer.tsx | 100 ++++++++++++------ 2 files changed, 79 insertions(+), 48 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 4c45cf1..8bc3e38 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 @@ -3,6 +3,7 @@ package com.rnstorybookautoscreenshots import android.util.Log import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableArray import org.json.JSONArray @@ -15,13 +16,13 @@ import java.util.concurrent.TimeUnit /** * Native module with three responsibilities: * - Receives the story list from JS and writes it to disk for test discovery. - * - Provides awaitNextStory(), a blocking synchronous JSI call that lets JS - * pull the next story ID from the test runner rather than receiving events. + * - Provides awaitNextStory(), a Promise-based pull that lets JS wait for the + * next story ID from the test runner rather than receiving events. * - Synchronises the test thread with JS rendering via a CountDownLatch. * * Communication flow: * Test thread → pushStory(id) → storyQueue - * JS thread ← awaitNextStory() ← storyQueue (blocks JS thread) + * JS thread ← await awaitNextStory() ← background thread ← storyQueue * JS thread → notifyStoryReady() * Test thread ← awaitStoryReady() ← storyReadyLatch */ @@ -93,18 +94,18 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas override fun getName(): String = "StorybookRegistry" /** - * Blocking synchronous JSI call — blocks the JS thread until the test runner - * pushes the next story ID via [pushStory]. + * Promise-based pull — resolves with the next story ID once the test runner + * pushes one via [pushStory], or null when the test runner signals done. * - * Returns the story ID string, or null when the test runner signals done. - * - * NOTE: isBlockingSynchronousMethod is deprecated in the new architecture. - * This is an experimental branch to evaluate whether synchronous pulling - * is cleaner than the event-based push model. + * The blocking queue.poll() runs on a background thread so the React Native + * JS thread is never blocked (replaces the deprecated isBlockingSynchronousMethod). */ - @ReactMethod(isBlockingSynchronousMethod = true) - fun awaitNextStory(): String? { - return storyQueue.poll(30, TimeUnit.SECONDS) + @ReactMethod + fun awaitNextStory(promise: Promise) { + Thread { + val storyId = storyQueue.poll(30, TimeUnit.SECONDS) + promise.resolve(storyId) + }.start() } /** diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index aa55fa9..e649094 100644 --- a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx +++ b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx @@ -44,19 +44,44 @@ export function registerStoriesWithNative() { } } +/** + * Builds the prepared story mapping without waiting for _preview.ready(). + * + * createPreparedStoryMapping() waits on storeInitializationPromise, which only + * resolves when the Storybook UI renders. In our test scenario the UI never + * renders, so it hangs. We bypass it by going directly to storyStoreValue, + * which is set synchronously during app startup before our surface mounts. + */ +async function buildPreparedStories() { + if (Object.keys(storybookView._idToPrepared).length > 0) { + return; // already populated + } + + const storyStore = storybookView._preview.storyStoreValue; + if (storyStore) { + await Promise.all( + Object.keys(storybookView._storyIndex.entries).map(async (storyId: string) => { + storybookView._idToPrepared[storyId] = await storyStore.loadStory({ storyId }); + }) + ); + } else { + await storybookView.createPreparedStoryMapping(); + } +} + /** * Renders Storybook stories for screenshot testing. * - * Instead of receiving a story name via prop or event, this component drives - * itself by calling the synchronous native method awaitNextStory(), which - * blocks the JS thread until the test runner enqueues the next story ID. + * Native controls the story order by pushing IDs into a queue. JS pulls each + * ID by awaiting awaitNextStory() (Promise resolved on a background thread), + * renders the story, notifies native via notifyStoryReady(), then awaits the + * next ID. This inverts the event-push model: JS pulls rather than native pushing. * * Flow: - * 1. Mount → register stories → call awaitNextStory() (blocks JS thread) - * 2. Test thread pushes story ID into the queue → JS unblocks - * 3. React re-renders the story - * 4. After commit: notifyStoryReady() releases native latch, then - * awaitNextStory() blocks again for the next story + * 1. Mount → register stories → await awaitNextStory() (waits for queue) + * 2. Test thread pushes story ID → Promise resolves → JS renders + * 3. After commit: notifyStoryReady() releases native latch + * 4. await awaitNextStory() again — native takes screenshot, then pushes next ID * 5. When native pushes null, the loop ends */ export function StoryRenderer() { @@ -64,33 +89,36 @@ export function StoryRenderer() { const [storyContent, setStoryContent] = useState(null); const [error, setError] = useState(null); - // Bootstrap: register stories then block waiting for the first story ID. + // Bootstrap: register stories then wait for the first story ID from native. useEffect(() => { - if (!storybookView) { - setError('Storybook not configured. Call configure(view) first.'); - return; - } + async function init() { + if (!storybookView) { + setError('Storybook not configured. Call configure(view) first.'); + return; + } - registerStoriesWithNative(); + registerStoriesWithNative(); - // awaitNextStory() is a blocking synchronous JSI call — it returns only - // when the test thread pushes a story ID (or null to signal done). - const firstId: string | null = StorybookRegistry.awaitNextStory(); - if (firstId !== null) { - setCurrentStoryId(firstId); + // awaitNextStory() resolves on a background thread once the test runner + // pushes the first story ID via pushStory(). + const firstId: string | null = await StorybookRegistry.awaitNextStory(); + if (firstId !== null) { + setCurrentStoryId(firstId); + } } + + init(); }, []); - // Render the current story whenever currentStoryId changes. + // Render the story for the current storyId. + // buildPreparedStories() is called lazily here to handle the first real story + // (bootstrap ID "__bootstrap__" won't have a prepared entry — that's expected). useEffect(() => { if (currentStoryId === null) return; async function renderStory() { try { - // Lazily populate _idToPrepared — createPreparedStoryMapping() is async. - if (!storybookView._idToPrepared || Object.keys(storybookView._idToPrepared).length === 0) { - await storybookView.createPreparedStoryMapping(); - } + await buildPreparedStories(); const preparedStory = storybookView._idToPrepared[currentStoryId!]; if (!preparedStory) { @@ -113,21 +141,23 @@ export function StoryRenderer() { renderStory(); }, [currentStoryId]); - // After each story commit: signal native, then block waiting for the next story. - // This effect fires once storyContent or error has settled for the current story. + // After each story commit: signal native, then await the next story ID. + // notifyStoryReady() releases the latch the test thread is waiting on. + // awaitNextStory() blocks (on a background thread) until native pushes the + // next ID — giving native time to take the screenshot first. useEffect(() => { if (currentStoryId === null) return; - // Signal native that the current story is fully rendered. - StorybookRegistry.notifyStoryReady(); - - // Block the JS thread until the test runner pushes the next story ID. - // While blocked, native takes the screenshot and advances its loop. - const nextId: string | null = StorybookRegistry.awaitNextStory(); - if (nextId !== null) { - setCurrentStoryId(nextId); + async function advance() { + StorybookRegistry.notifyStoryReady(); + const nextId: string | null = await StorybookRegistry.awaitNextStory(); + if (nextId !== null) { + setCurrentStoryId(nextId); + } + // else: null means all stories are done — stop here. } - // else: null means all stories are done — we stop here. + + advance(); }, [storyContent, error]); if (error) { From 16e53e78d7fa69603ae49f0aafde6ba39ab4398f Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Mar 2026 12:16:19 -0400 Subject: [PATCH 3/5] fix: use sentinel instead of null in storyQueue to avoid NPE LinkedBlockingQueue.put(null) throws NullPointerException. Replace the nullable queue with a String queue and map null to/from a DONE_SENTINEL so callers can still use pushStory(null) to signal completion. Co-Authored-By: Claude Sonnet 4.6 --- .../rnstorybookautoscreenshots/StorybookRegistry.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 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 8bc3e38..3fd1737 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 @@ -34,7 +34,9 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas // Capacity 1 so pushStory() blocks until JS has consumed the current entry, // giving natural back-pressure between the test runner and JS. - private val storyQueue = LinkedBlockingQueue(1) + // LinkedBlockingQueue does not allow null, so we use a sentinel to signal done. + private const val DONE_SENTINEL = "__done__" + private val storyQueue = LinkedBlockingQueue(1) @Volatile private var storyReadyLatch: CountDownLatch? = null @@ -45,7 +47,7 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas * Blocks until JS has consumed the previous entry (queue capacity = 1). */ fun pushStory(storyId: String?) { - storyQueue.put(storyId) + storyQueue.put(storyId ?: DONE_SENTINEL) } /** @@ -103,8 +105,8 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas @ReactMethod fun awaitNextStory(promise: Promise) { Thread { - val storyId = storyQueue.poll(30, TimeUnit.SECONDS) - promise.resolve(storyId) + val raw = storyQueue.poll(30, TimeUnit.SECONDS) + promise.resolve(if (raw == DONE_SENTINEL) null else raw) }.start() } From 55ae22145f167ecb3bd53af8226b9c7dc89602f9 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Mar 2026 12:37:09 -0400 Subject: [PATCH 4/5] fix: add ViewHelpers layout before Screenshot.snap on new arch Without an explicit measure/layout pass, Screenshot.snap(surface.view) captures a blank bitmap on Fabric. Mirror what the old arch path already does with ViewHelpers so the view tree is software-rendered and properly sized before capture. Co-Authored-By: Claude Sonnet 4.6 --- .../com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt index 28cd9b7..dc8c654 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 @@ -132,6 +132,10 @@ abstract class BaseStoryScreenshotTest { val screenshotName = story.id.replace("--", "_") instrumentation.runOnMainSync { + ViewHelpers.setupView(view) + .setExactWidthPx(SCREEN_WIDTH_PX) + .setExactHeightPx(SCREEN_HEIGHT_PX) + .layout() Screenshot.snap(view).setName(screenshotName).record() } Log.d(TAG, "Screenshot captured: $screenshotName") From c8ceb5fdd3c72bd792b2e9d86d743dde6558ffa9 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Mar 2026 13:50:34 -0400 Subject: [PATCH 5/5] fix: recursively set LAYER_TYPE_SOFTWARE before Screenshot.snap ViewHelpers only sets software layer on the root view. Fabric child views retain hardware display lists which view.draw(canvas) cannot capture, producing blank (white background only) screenshots. Walk the full tree and set LAYER_TYPE_SOFTWARE on every node before snapping. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt index dc8c654..0aa0c4f 100644 --- a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt +++ b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt @@ -7,6 +7,7 @@ import android.util.Log import android.view.Choreographer import android.view.ContextThemeWrapper import android.view.View +import android.view.ViewGroup import android.view.WindowManager import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule @@ -132,10 +133,9 @@ abstract class BaseStoryScreenshotTest { val screenshotName = story.id.replace("--", "_") instrumentation.runOnMainSync { - ViewHelpers.setupView(view) - .setExactWidthPx(SCREEN_WIDTH_PX) - .setExactHeightPx(SCREEN_HEIGHT_PX) - .layout() + // view.draw(canvas) can't capture children that have hardware display + // lists. Force the entire tree to software so draw() sees all content. + setLayerTypeSoftwareRecursively(view) Screenshot.snap(view).setName(screenshotName).record() } Log.d(TAG, "Screenshot captured: $screenshotName") @@ -233,6 +233,20 @@ abstract class BaseStoryScreenshotTest { } } + /** + * Recursively sets LAYER_TYPE_SOFTWARE on every view in the tree. + * view.draw(canvas) skips children that have hardware display lists; + * forcing software rendering on all nodes ensures the full tree is captured. + */ + private fun setLayerTypeSoftwareRecursively(view: View) { + view.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + setLayerTypeSoftwareRecursively(view.getChildAt(i)) + } + } + } + private fun waitTwoFrames() { val instrumentation = InstrumentationRegistry.getInstrumentation() repeat(2) {