On-device Controls stop working after Fast Refresh: "cannot render when canvasElement is unset"

Author: ekrapflCreated Sep 17, 2026Updated Sep 24, 2026

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

Minimal reproduction

bash
npm install
npm run ios      # or: npm run android
  1. Storybook opens on Example/Button → Configurable. Open the Controls panel and confirm a size radio option resizes the button normally.
  2. 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 plain Preview object) and any CSF story file (meta/StoryObj exports) — and save both within well under 100ms of each other. Neither file gets its own React Refresh boundary, so both edits bubble to the same storybook.requires.ts accept boundary; when one Metro delivery covers both, the accept handler (and therefore updateView()) runs once per affected module, synchronously, with no await between the calls. A shell one-liner that reliably hits this window:
    bash
    printf '\n// touch\n' >> .rnstorybook/preview.tsx && \
      sed -i '' "s/label: '[^']*'/label: 'edited'/" .rnstorybook/stories/Button.stories.tsx
  3. Touch a size radio 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):

javascript
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 internal renderSelection() call (storybook/dist/preview/runtime.js) is itself unawaited, independent of @storybook/react-native's own updateView() not awaiting its three calls — so even a fix that serializes updateView()'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:

javascript
// @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:

javascript
[
  { 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:

javascript
(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