On-device Controls stop working after Fast Refresh: "cannot render when canvasElement is unset"
Describe the bug
After a Fast Refresh, the on-device Controls addon can stop working entirely: touching a
control throws Error: cannot render when canvasElement is unset (and sometimes
Error: cannot render when not prepared), the story does not update, and this does not self-heal
on further Fast Refreshes — only a full reload (not Fast Refresh) fixes it.
To Reproduce
npm install
npm run ios # or: npm run android- Storybook opens on
Example/Button→Configurable. Open the Controls panel and confirm asizeradio option resizes the button normally. - Edit two files that both compose into the Storybook preview but are not themselves React
components — e.g.
.rnstorybook/preview.tsx(its default export is a plainPreviewobject) and any CSF story file (meta/StoryObjexports) — and save both within well under 100ms of each other. Neither file gets its own React Refresh boundary, so both edits bubble to the samestorybook.requires.tsaccept boundary; when one Metro delivery covers both, the accept handler (and thereforeupdateView()) runs once per affected module, synchronously, with noawaitbetween the calls. A shell one-liner that reliably hits this window:printf '\n// touch\n' >> .rnstorybook/preview.tsx && \ sed -i '' "s/label: '[^']*'/label: 'edited'/" .rnstorybook/stories/Button.stories.tsx - Touch a
sizeradio option again (a different one than currently selected).
Deterministic proof, independent of file-save timing. The repro also exposes a debug hook that
calls updateView() twice back-to-back from a JS debugger (debugger-evaluate in our tooling, or
any eval-capable RN debugger):
globalThis.__updateViewDebug();
globalThis.__updateViewDebug();This corrupted preview.storyRenders (per the duplicate shown above) on every attempt.
Confirmed happening from a single, ordinary edit in a real app. In the production app this was
originally found in — expo-router-based, ~90 story files, a project-level decorator that reads a
persisted preference on every story mount — a single edit to one story file's args
reliably (4/4 measured trials, fresh app launch each time) fires updateView() twice, ~25–27ms
apart, confirmed via a one-line counter added to storybook.requires.ts:
[diag] updateView call # 3 at 1789669214407
[diag] updateView call # 4 at 1789669214432— and this is what breaks Controls on the very first Fast Refresh, every time, for that app. We
were not able to isolate which specific piece of that app's setup causes Metro to deliver two
updates for one file change; we ruled out (each tested in isolation in the minimal repro, on both
iOS and Android): NativeWind / react-native-css, an ~85-file story index (matching that app's
scale), an artificial async delay in a CSF loaders entry, a deliberately deep decorator render
tree, React Compiler (present in both), multiple story exports in the edited file, and nesting
Storybook under expo-router via a dynamic require() (matching that app's actual entry
structure) instead of Storybook being the bundle's own main. None of these made a single edit in
the minimal repro fire updateView() more than once — only the deliberate two-file edit above
does, in the minimal repro.
Expected behavior
Fast Refresh should never leave the on-device Controls addon unable to update a story — at most, we'd expect a story to remount if its implementation genuinely changed, not the args-update pipeline breaking until a full reload.
Code snippets
Full detail, including the internal-state dumps and everything tried to widen/narrow the reproduction, is in the minimal repro's README.
System
Storybook Environment Info:
System:
OS: macOS 26.6.2
CPU: (10) arm64 Apple M5
Shell: 5.9 - /bin/zsh
Binaries:
Node: 24.14.0
npm: 11.9.0
npmPackages:
@storybook/addon-ondevice-actions: latest => 10.6.0
@storybook/addon-ondevice-controls: latest => 10.6.0
@storybook/react-native: latest => 10.6.0
@storybook/react-native-ui-lite: latest => 10.6.0
storybook: ^10.6.0 => 10.6.0(npx storybook@latest info only reports Storybook-scoped packages, hence the explicit line
above.) Other relevant versions: expo-router ~57.0.21, react-native 0.86.3, react 19.2.3,
Fabric (New Architecture) — confirmed on both an iOS simulator (iOS 26.4) and an Android emulator
(API 37). The production app where this was originally found is also on Expo SDK 57 / React
Native 0.86.3.
Additional context
- This reproduces identically on Android and iOS.
onGetProjectAnnotationsChanged's internalrenderSelection()call (storybook/dist/preview/runtime.js) is itself unawaited, independent of@storybook/react-native's ownupdateView()not awaiting its three calls — so even a fix that serializesupdateView()'s three calls internally does not fully close this (we tried; the corruption still occurred, just needed a different interleaving to trigger).
Potential root cause: updateView() (@storybook/react-native/dist/index.js) — the function every
generated storybook.requires.ts calls on Fast Refresh — fires three internal preview-update
calls without awaiting each other:
// @storybook/react-native/dist/index.js
async function updateView(viewInstance, annotations, normalizedStories, options) {
...
viewInstance._preview.onStoriesChanged({ importFn: ... }); // not awaited
viewInstance._preview.onGetProjectAnnotationsChanged({ getProjectAnnotations: ... }); // not awaited
viewInstance._storyIndex = index;
viewInstance._preview.onStoryIndexChanged().then(() => { ... }); // not awaited
}Each of these can independently call Storybook core's PreviewWithSelection.renderSelection()
(storybook/dist/preview/runtime.js) for the currently-selected story, and
onGetProjectAnnotationsChanged doesn't await its own internal renderSelection() call either.
renderSelection() is not reentrant-safe: if it runs more than once concurrently for the same
story, a StoryRender can get pushed into preview.storyRenders and torn down (or vice versa)
before it's ever handed to renderToElement(). That leaves a stale, torn-down StoryRender for
the same story id sitting alongside the live one:
[
{ id: 'example-button--configurable', phase: 'preparing', torndown: true, canvasElement: null }, // stale
{ id: 'example-button--configurable', phase: 'finished', torndown: false, canvasElement: {...} }, // live
]The next time a Controls widget is touched, the addon dispatches UPDATE_STORY_ARGS, and
onUpdateArgs does:
(this.storyStoreValue.args.update(storyId, updatedArgs),
await Promise.all(
this.storyRenders
.filter((r) => r.id === storyId && !r.renderOptions.forceInitialArgs)
.map((r) => (r.story && r.story.usesMount ? r.remount() : r.rerender())),
),
this.channel.emit(STORY_ARGS_UPDATED, {
storyId,
args: this.storyStoreValue.args.get(storyId),
}));This matches every StoryRender with that id — live and stale — and calls .rerender() on
all of them. The args store update itself (the first statement) always succeeds, and the live
render's .rerender() picks it up and repaints correctly, but the stale render's .rerender()
throws (canvasElement was never set on it). Because Promise.all then rejects,
channel.emit(STORY_ARGS_UPDATED, ...) — which is what the Controls panel listens for to reflect
the new value in its own UI — never fires. Net effect, depending on how many live vs. stale
renders exist at that moment: either nothing visibly updates, or (if there's still a live render)
the canvas updates correctly but the control itself (e.g. a radio button) does not move to reflect
the new selection, and the console still logs the error.
Source: storybookjs/react-native