[Bug]: Actions: SecurityError serializing a React event when the manager contains a cross-origin iframe
Describe the bug
A story whose instrumented fn() callback is actually invoked, and whose callback receives a React synthetic event, fails in the Storybook UI with an unhandled SecurityError — while every assertion in the play function passes. The Interactions panel shows all steps green and still reports FAIL, because the run carries one unhandled error.
The failure requires a cross-origin iframe to be present in the manager document. Any cross-origin frame is enough; no particular addon is involved.
SecurityError: Failed to read a named property 'toJSON' from 'Window':
Blocked a frame with origin "http://localhost:6006" from accessing a cross-origin frame.
at stringify (…/sb-vite/deps/storybook_internal_preview_runtime.js)
at PostMessageTransport.send (…)
at Channel.emit (…)
at handler (…)
The CLI test run (vitest --project=storybook) is not affected — it has no manager and therefore no cross-origin frame — so CI stays green while the interactive panel shows a red FAIL with green assertions.
Conditions, as measured All four hold together; removing any one makes the failure disappear:
- the story runs in the Storybook UI (manager + preview), not the CLI runner;
- an instrumented fn() callback is actually invoked;
- the callback receives a React synthetic event;
- the manager document contains a cross-origin iframe.
Reproduction link
https://github.com/OleksandrHoncharov/storybook-10-6-interaction-securityerror-repro
Reproduction steps
Isolated project A clean project created for this report, unrelated to any design system or product code.
- Node 22.22.1
- React / react-dom 19.3.0
- storybook 10.6.0, @storybook/react-vite 10.6.0, @storybook/addon-vitest 10.6.0
- Vitest 4.1.11, @vitest/browser-playwright 4.1.11
- Only the official addon-vitest. No third-party addons of any kind.
The story renders a native element and nothing else:
// Repro.stories.tsx
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
const meta: Meta = { title: "Repro" };
export default meta;
export const Repro: StoryObj = {
args: { onClick: fn() },
render: (args) => (
<button type="button" onClick={args.onClick}>
Click me
</button>
),
play: async ({ args, canvasElement }) => {
const button = await within(canvasElement).findByRole("button", { name: "Click me" });
await userEvent.click(button);
await expect(args.onClick).toHaveBeenCalledTimes(1);
},
};
Steps
- Run the CLI test suite. It passes — 4/4.
- Open the story in the Storybook UI. Initial run passes, Rerun passes.
- Add a cross-origin iframe to the manager document, from the browser console on the Storybook page:
const iframe = document.createElement("iframe");
iframe.src = "https://example.com";
iframe.style.display = "none";
document.body.appendChild(iframe);
- Run the story's play function.
Expected The run behaves as it does without the cross-origin frame: all assertions pass, the panel reports PASS.
Actual The panel reports FAIL. The interaction steps are green; the failure comes entirely from an Unhandled Errors block containing the SecurityError above.
Matrix — cross-origin iframe present in every row
| Story callback | Callback invoked | Result |
|---|---|---|
| onClick={args.onClick} | yes | FAIL — SecurityError |
| onClick={() => args.onClick()} | yes | PASS |
| onClick={args.onClick}, element disabled | no | PASS |
The second row is the control that matters most: wrapping the spy so it receives no arguments makes the failure disappear under an otherwise identical setup, including the same cross-origin iframe. The spy still runs and the assertion still passes.
Not Rerun-specific An earlier read of this defect described it as reproducing only on an explicit Rerun. That is retracted. In the isolated project the error fires on the initial play run when the cross-origin iframe already exists in the manager before the story runs. Rerun was only the practical trigger in the environment where this was first observed — see Additional context.
System
Storybook Environment Info:
System:
OS: macOS 14.8.1
CPU: (10) arm64 Apple M1 Pro
Shell: 5.9 - /bin/zsh
Binaries:
Node: 22.22.1 - /Users/user/.nvm/versions/node/v22.22.1/bin/node
Yarn: 1.22.19 - /usr/local/bin/yarn
npm: 10.9.4 - /Users/user/.nvm/versions/node/v22.22.1/bin/npm <----- active
pnpm: 9.15.9 - /Users/user/.nvm/versions/node/v22.22.1/bin/pnpm
Browsers:
Chrome: 153.0.8010.36
Firefox: 145.0.2
Safari: 26.1
npmPackages:
@storybook/addon-vitest: 10.6.0 => 10.6.0
@storybook/react-vite: 10.6.0 => 10.6.0
storybook: 10.6.0 => 10.6.0
Additional context
The argument reaching the spy
Captured in the isolated project at the moment the callback fires:
- constructor — SyntheticBaseEvent
- nativeEvent — PointerEvent
- view — Window
- view === window — true
- persist — present
What we did not establish
We deliberately stop at an evidence boundary and do not claim a root cause.
- We did not confirm the internal traversal that reaches toJSON on a Window.
- serializeArg substitutes a stub for view, but behind an isReactSyntheticEvent gate. We did not establish what the argument actually is at that point — only that the substitution does not take effect in this scenario.
- We did not establish why an identical spy invoked through a zero-argument wrapper is unaffected, beyond the obvious fact that it receives no event.
A second, independent environment The same failure was first observed in a React 19 monorepo with a Vite-based Storybook 10.6.0, on components unrelated to the isolated repro. In that environment the cross-origin frame came from an addon that embeds external content — the frame is created lazily and, once created, stays live in the manager DOM even after switching to a different panel tab. That is why the failure there looked intermittent and tied to Rerun: the frame only existed after the embedding panel had been opened once. The addon is not required and is not part of this report. A hand-injected reproduces it identically with no third-party addon installed.
In that second environment the thrown error was observed in the preview window, not the manager, measured with error listeners installed on both windows before the run.
Workaround
Disabling the actions addon for the affected story removes the failure completely:
export const Repro: StoryObj = {
args: { onClick: fn() },
parameters: { actions: { disable: true } },
// render and play unchanged
};
Measured in the second environment, with the cross-origin frame present and everything else unchanged:
| actions.disable | Actions panel | Interactions | Unhandled errors | SecurityError count |
|---|---|---|---|---|
| true | tab absent | PASS | none | 0 |
| unset (control) | tab present | FAIL | 1 | 1 |
The instrumented fn() keeps working — expect(onClick).toHaveBeenCalledTimes(1) still passes — and the CLI suite is unchanged. The only loss is the Actions panel for that story.
This places the failing serialization on the action-logging channel emit, not on the spy itself. We report that as a boundary, not as a diagnosis.
An attempted fix that did not work We tested a local patch changing action(name)(args) to action(name)(...args) in dist/preview/runtime.js and dist/csf/index.js, so that serializeArg would receive each argument rather than the array. It was insufficient. With the patch verified present in the runtime the dev server actually served, the matrix above was unchanged. The patch has been reverted; every version reported here is unmodified.
Prior art storybookjs/telejson#71 describes the same class of failure — serialization calling toJSON on a cross-origin object when an action carries a native Event and the page has a cross-origin iframe. That issue is still open, but telejson is no longer a dependency of Storybook 10.6, so the affected code now lives elsewhere.
storybookjs/storybook#7215 records a configureActions({ depth: 2 }) workaround from Storybook 5. In 10.6 the depth is computed as minDepth + (depth || 3) with minDepth fixed at 5, so that lever no longer reaches a shallow enough traversal.
Impact Low severity, high confusion. CI stays green while the interactive panel reports a red FAIL with every assertion green, which reads as a broken test rather than an environment problem.
Source: storybookjs/storybook