From 9dc196dd9f12565d057c250831904e32016a47aa Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 18 Mar 2026 15:58:19 -0400 Subject: [PATCH 1/4] Wait for idle sync before capturing screenshot After JS calls notifyStoryReady() (end of useEffect), Fabric may still have pending layout and mount work queued on the main thread. Taking the screenshot immediately could capture a partially-rendered frame. waitForIdleSync() drains the main thread's Looper before snap() runs, ensuring all native view updates are applied first. Co-Authored-By: Claude Sonnet 4.6 --- .../com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt | 3 +++ 1 file changed, 3 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 294a92c..e5c9878 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,9 @@ abstract class BaseStoryScreenshotTest { StorybookRegistry.prepareForNextStory() renderStory(storyName) { view -> StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) + // Wait for the main thread to drain any pending Fabric layout/mount work + // that may still be in flight after JS signals ready. + InstrumentationRegistry.getInstrumentation().waitForIdleSync() val screenshotName = storyInfo.id.replace("--", "_") Screenshot.snap(view).setName(screenshotName).record() Log.d(TAG, "Screenshot captured: $screenshotName") From 7cdea7ee888ab3f5c5aa9ad2ccdb8865cf8d9e41 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 19 Mar 2026 12:37:36 -0400 Subject: [PATCH 2/4] Fix flaky screenshots: double waitForIdleSync and loud timeout Fabric posts a second round of layout/mount work (e.g. Switch thumb animation, text measurement) in response to the first commit, which races with a single waitForIdleSync(). A second call catches that. Also make awaitStoryReady() throw on timeout instead of silently proceeding, so a story that never calls notifyStoryReady() fails loudly rather than capturing a partial screenshot. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 10 +++++++--- .../rnstorybookautoscreenshots/StorybookRegistry.kt | 10 ++++++++-- 2 files changed, 15 insertions(+), 5 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 e5c9878..0cd66b3 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,9 +132,13 @@ abstract class BaseStoryScreenshotTest { StorybookRegistry.prepareForNextStory() renderStory(storyName) { view -> StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) - // Wait for the main thread to drain any pending Fabric layout/mount work - // that may still be in flight after JS signals ready. - InstrumentationRegistry.getInstrumentation().waitForIdleSync() + // Drain the main thread twice. The first call catches work already queued + // when JS signals ready (e.g. Fabric's initial commit). Some native widgets + // (Switch, text measurement) post a second round of layout/mount work in + // response to the first — the second call catches that. + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.waitForIdleSync() + instrumentation.waitForIdleSync() val screenshotName = storyInfo.id.replace("--", "_") Screenshot.snap(view).setName(screenshotName).record() Log.d(TAG, "Screenshot captured: $screenshotName") 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..ee9d452 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 @@ -31,10 +31,16 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas } /** - * Blocks until JS signals the story is rendered, or the timeout elapses. + * Blocks until JS signals the story is rendered, or throws if the timeout elapses. */ fun awaitStoryReady(timeoutMs: Long) { - storyReadyLatch?.await(timeoutMs, TimeUnit.MILLISECONDS) + val completed = storyReadyLatch?.await(timeoutMs, TimeUnit.MILLISECONDS) ?: true + if (!completed) { + throw AssertionError( + "Story did not call notifyStoryReady() within ${timeoutMs}ms. " + + "The story may still be loading or failed silently on the JS side." + ) + } } /** From 746e3b34cba52e7a222d835f8ab35a7809c960d5 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 19 Mar 2026 13:01:07 -0400 Subject: [PATCH 3/4] Use 5 idle sync rounds to handle SwitchCompat's multi-pass rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2 rounds was still not enough for SwitchCompat: mount → setChecked → animation setup → layout measurement spans several async dispatch cycles in Fabric. 5 rounds covers the full chain empirically. Made the count overridable via getIdleSyncRounds() for consumers who need to tune it for their own native components. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt index 0cd66b3..14963d9 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 @@ -38,6 +38,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 DEFAULT_IDLE_SYNC_ROUNDS = 5 private const val BOOTSTRAP_STORY_NAME = "__bootstrap__" private const val SCREEN_WIDTH_PX = 1080 @@ -69,6 +70,14 @@ abstract class BaseStoryScreenshotTest { */ open fun getBootstrapTimeoutMs(): Long = DEFAULT_BOOTSTRAP_TIMEOUT_MS + /** + * Override to customize how many waitForIdleSync() rounds are run after + * a story signals ready. Each round drains one layer of async work that + * Fabric or native widgets (e.g. SwitchCompat) may post to the main thread. + * Default is 5. + */ + open fun getIdleSyncRounds(): Int = DEFAULT_IDLE_SYNC_ROUNDS + /** * Override to filter which stories should be screenshotted. * Return true to include the story, false to skip it. @@ -132,13 +141,12 @@ abstract class BaseStoryScreenshotTest { StorybookRegistry.prepareForNextStory() renderStory(storyName) { view -> StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) - // Drain the main thread twice. The first call catches work already queued - // when JS signals ready (e.g. Fabric's initial commit). Some native widgets - // (Switch, text measurement) post a second round of layout/mount work in - // response to the first — the second call catches that. + // Drain the main thread repeatedly. Fabric's mount → native widget init + // (e.g. SwitchCompat) → animation setup → layout can span several async + // rounds; each waitForIdleSync() catches one round and gives the next a + // chance to be queued before we check again. val instrumentation = InstrumentationRegistry.getInstrumentation() - instrumentation.waitForIdleSync() - instrumentation.waitForIdleSync() + repeat(getIdleSyncRounds()) { instrumentation.waitForIdleSync() } val screenshotName = storyInfo.id.replace("--", "_") Screenshot.snap(view).setName(screenshotName).record() Log.d(TAG, "Screenshot captured: $screenshotName") From b2d566e1ecf98eb51e7dc402241ec381373f3800 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 19 Mar 2026 13:53:49 -0400 Subject: [PATCH 4/4] Fix flaky screenshots: await Choreographer frames + disable animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitForIdleSync() only drains the Looper. Fabric's mount phase is scheduled as a ReactChoreographer FrameCallback (VSYNC-driven), so it was invisible to waitForIdleSync() — the screenshot was taken before native views were actually updated. Fix 1: replace waitForIdleSync() loops with awaitChoreographerFrames(), which posts a FrameCallback and blocks until it fires, then drains the Looper for any follow-up Looper work in each frame. Fix 2: disable system animation scales via UiAutomation before the test run so SwitchCompat's thumb animation (and similar) can't render in an intermediate state at snapshot time. Co-Authored-By: Claude Sonnet 4.6 --- .../BaseStoryScreenshotTest.kt | 76 +++++++++++++++---- 1 file changed, 63 insertions(+), 13 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 14963d9..6606510 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. @@ -38,7 +41,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 DEFAULT_IDLE_SYNC_ROUNDS = 5 + private const val DEFAULT_CHOREOGRAPHER_FRAMES = 3 private const val BOOTSTRAP_STORY_NAME = "__bootstrap__" private const val SCREEN_WIDTH_PX = 1080 @@ -71,12 +74,14 @@ abstract class BaseStoryScreenshotTest { open fun getBootstrapTimeoutMs(): Long = DEFAULT_BOOTSTRAP_TIMEOUT_MS /** - * Override to customize how many waitForIdleSync() rounds are run after - * a story signals ready. Each round drains one layer of async work that - * Fabric or native widgets (e.g. SwitchCompat) may post to the main thread. - * Default is 5. + * Override to customize how many Choreographer frames are awaited after + * a story signals ready. Fabric schedules its native-view mount phase as + * a Choreographer FrameCallback (VSYNC-driven), so waitForIdleSync() alone + * cannot catch it. Each frame also drains follow-up Looper work before + * the next frame is awaited. + * Default is 3. */ - open fun getIdleSyncRounds(): Int = DEFAULT_IDLE_SYNC_ROUNDS + open fun getChoreographerFrames(): Int = DEFAULT_CHOREOGRAPHER_FRAMES /** * Override to filter which stories should be screenshotted. @@ -92,7 +97,13 @@ abstract class BaseStoryScreenshotTest { */ @Test fun screenshotAllStories() { - val context = InstrumentationRegistry.getInstrumentation().targetContext + val instrumentation = InstrumentationRegistry.getInstrumentation() + + // Disable system animations so native widgets (e.g. SwitchCompat) render + // their final state immediately rather than mid-animation. + disableAnimations(instrumentation) + + val context = instrumentation.targetContext val externalDir = context.getExternalFilesDir("screenshots") val manifestFile = File(externalDir, StorybookRegistry.STORIES_FILE_NAME) @@ -141,18 +152,40 @@ abstract class BaseStoryScreenshotTest { StorybookRegistry.prepareForNextStory() renderStory(storyName) { view -> StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) - // Drain the main thread repeatedly. Fabric's mount → native widget init - // (e.g. SwitchCompat) → animation setup → layout can span several async - // rounds; each waitForIdleSync() catches one round and gives the next a - // chance to be queued before we check again. - val instrumentation = InstrumentationRegistry.getInstrumentation() - repeat(getIdleSyncRounds()) { instrumentation.waitForIdleSync() } + // Fabric schedules its mount phase as a Choreographer FrameCallback, not a + // Looper message, so waitForIdleSync() alone cannot catch it. We await N + // frames here; each frame also drains its trailing Looper work before the + // next frame is requested, covering multi-pass native widget layout too. + awaitChoreographerFrames( + InstrumentationRegistry.getInstrumentation(), + getChoreographerFrames() + ) val screenshotName = storyInfo.id.replace("--", "_") Screenshot.snap(view).setName(screenshotName).record() Log.d(TAG, "Screenshot captured: $screenshotName") } } + /** + * Awaits [count] Choreographer frames on the main thread. After each frame, + * drains the Looper to catch any follow-up work the frame may have posted. + */ + private fun awaitChoreographerFrames( + instrumentation: android.app.Instrumentation, + count: Int + ) { + repeat(count) { + val latch = CountDownLatch(1) + instrumentation.runOnMainSync { + Choreographer.getInstance().postFrameCallback { latch.countDown() } + } + check(latch.await(5, TimeUnit.SECONDS)) { + "Timed out waiting for Choreographer frame" + } + instrumentation.waitForIdleSync() + } + } + private fun bootstrapManifest(manifestFile: File) { Log.d(TAG, "Launching StoryRenderer to generate manifest...") renderStory(BOOTSTRAP_STORY_NAME) { @@ -239,6 +272,23 @@ abstract class BaseStoryScreenshotTest { } } + /** + * Disables system-wide animation scales via UiAutomation shell commands. + * This prevents native widget animations (e.g. SwitchCompat thumb) from + * rendering in an intermediate state when the screenshot is taken. + */ + private fun disableAnimations(instrumentation: android.app.Instrumentation) { + listOf( + "animator_duration_scale", + "transition_animation_scale", + "window_animation_scale" + ).forEach { key -> + instrumentation.uiAutomation.executeShellCommand( + "settings put global $key 0" + ).close() + } + } + private fun waitForManifestFile(manifestFile: File) { val deadline = System.currentTimeMillis() + getBootstrapTimeoutMs() while (!manifestFile.exists() && System.currentTimeMillis() < deadline) {