Skip to content

Use loadStory to switch stories on a single surface instead of remounting#92

Draft
EmilioBejasa wants to merge 12 commits intomainfrom
fix/use-load-story-for-screenshots
Draft

Use loadStory to switch stories on a single surface instead of remounting#92
EmilioBejasa wants to merge 12 commits intomainfrom
fix/use-load-story-for-screenshots

Conversation

@EmilioBejasa
Copy link
Copy Markdown
Collaborator

Summary

  • Previously a new ReactSurface was created and destroyed for each story, forcing createPreparedStoryMapping() to re-run every time. On a slow CI emulator this caused awaitStoryReady() to time out, producing screenshots of the "Loading story..." state.
  • Now a single surface stays alive for all stories. The first story is the initial prop; subsequent stories are switched by emitting the loadStory native event (from PR Add loadStory event emitter (native→JS) #74), which updates activeStoryName state in StoryRenderer and triggers a re-render. _idToPrepared is built once and reused.

Changes

  • StoryRenderer.tsx: loadStory event now drives re-renders via activeStoryName state instead of just logging
  • StorybookRegistry.kt: expose static loadStory() via companion object so the test can call it
  • BaseStoryScreenshotTest.kt: render one surface, loop through stories using loadStory() to switch

Test plan

  • Draft PR — verify CI passes and no "Loading story..." screenshots appear

…ting

Previously a new ReactSurface was created and destroyed for each story,
forcing createPreparedStoryMapping() to re-run every time. On a slow CI
emulator this caused awaitStoryReady() to time out, producing screenshots
of the "Loading story..." state.

Now a single surface is kept alive for all stories. The first story is
passed as the initial prop; subsequent stories are switched by emitting
the loadStory native event, which updates activeStoryName state in
StoryRenderer and triggers a re-render. _idToPrepared is built once on
the first render and reused for all subsequent switches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EmilioBejasa and others added 11 commits March 23, 2026 17:47
NativeEventEmitter(module) requires the native module to implement
addListener/removeListeners — StorybookRegistry doesn't, so events were
silently dropped. The native side emits via DeviceEventManagerModule,
so the correct JS receiver is DeviceEventEmitter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
notifyStoryReady() fires in JS useEffect after React's commit, but
Fabric dispatches view-tree mutations to the main thread asynchronously.
Without a sync point, the test thread could screenshot the stale (first
story) view before the main thread has applied the new story's mutations.

runOnMainSync {} flushes all pending main-thread tasks — including
Fabric's mutations — before Screenshot.snap().record() draws the view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ming

runOnMainSync{} only drains the Handler queue. Fabric dispatches mount
items via postFrameCallback (Choreographer), which is separate from the
Handler — so runOnMainSync can return before Fabric has applied the new
story's mutations.

Posting our own postFrameCallback ensures Fabric's earlier-registered
callback (queued during the React commit, before useEffect fired) always
runs first in the same vsync. The native view hierarchy is guaranteed to
reflect the current story before Screenshot.snap().record() draws it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The library detects when it's already on the UI thread and skips its
own Handler dispatch, so there's no deadlock. Being explicit here makes
it clear that the draw always happens on the main thread, consistent
with the Choreographer frame wait above.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DeviceEventEmitter emission from native is unreliable in bridgeless
new-arch mode (RN 0.82 + newArchEnabled=true), causing awaitStoryReady
to time out and every screenshot to show the first story. Fix by
mounting a fresh surface for each story with the correct storyName prop,
removing the need for loadStory events entirely. Also adds try/finally
to renderStory so surface cleanup always runs even if the lambda throws.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
storyNameToId() only split on the first "/" so stories with hierarchical
titles like "Example/Button" produced wrong IDs (e.g. "example--button"
instead of "example-button--primary"), causing those stories to fail
with "story not found".

Fix by passing story.id (already the correct Storybook ID from the
manifest) directly as the storyName prop and using it verbatim in
StoryRenderer, removing the lossy title/name round-trip entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bootstrap was tearing down as soon as the manifest file appeared, but
createPreparedStoryMapping() was still running async. Stories starting
right after bootstrap found _idToPrepared empty and had to wait for it
themselves, non-deterministically exceeding the 5s timeout.

Fix: prepareForNextStory() before bootstrap so awaitStoryReady() can
block until JS calls notifyStoryReady() — which only fires after
createPreparedStoryMapping() completes. _idToPrepared is then fully
populated before any story surface starts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The manifest persists on device between test runs, so the conditional
bootstrap was skipped on re-runs and _idToPrepared was empty when the
story loop started. Stories then had to await createPreparedStoryMapping
themselves, non-deterministically hitting the 5s timeout.

Fix: unconditionally run bootstrapManifest before the story loop so
_idToPrepared is always populated and the manifest is always fresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Instead of remounting a fresh ReactSurface for every story, keep one
surface alive for the entire test run. After the bootstrap render
populates the story manifest and _idToPrepared, each story is switched
by emitting a loadStory event from the main thread and waiting on a
CountDownLatch that notifyStoryReady() releases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getJSModule(RCTDeviceEventEmitter) is a bridge-era API that returns null
in bridgeless/new-arch mode, silently dropping the event. emitDeviceEvent
is the current API used by RN's own DeviceEventManagerModule internals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant