storybook
storybook copied to clipboard
addon-interactions: canvasElement returns empty #root div element
Describe the bug
While trying to test my Vue 2 component with addon-interactions, the canvasElement
returns an empty #root div element.
I can see the story rendered in the DOM, but canvasElement
does not include the nested story element.
It's kind of weird. when I switch between stories, it works. only on the first load does it fail to find the story element.
import AppButton, { allowedStates, allowedSizes, allowedTypes } from '@/components/AppButton.vue';
import { within, userEvent } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
export default {
title: 'Components/AppButton',
component: AppButton,
argTypes: {
default: { control: { type: 'text' } },
icon: { control: { type: 'text' } },
size: { options: allowedSizes, control: { type: 'select' } },
state: { options: allowedStates, control: { type: 'select' } },
type: { options: allowedTypes, control: { type: 'select' } },
click: { action: 'click' }
}
};
const Template = (args, { argTypes }) => ({
props: Object.keys(args),
components: { AppButton },
template:
`<app-button v-bind="$props" @click="click">
<template v-if="$props.default"><div v-html="$props.default" /></template>
<template v-if="$props.icon" #icon><div v-html="$props.icon" /></template>
</app-button>`
});
export const Default = Template.bind({});
Default.args = {
state: 'primary',
size: 'medium',
type: 'button',
default: '<div>Hello</div>'
};
Default.play = async({ args, canvasElement }) => {
// Starts querying the component from its root element
const canvas = within(canvasElement);
console.log(canvasElement); // <div id="root"></div> <================================= EMPTY ELEMENT
const button = await canvas.queryByTestId('app-button');
await expect(button).toBeTruthy();
await userEvent.click(button);
await expect(within(button).getByText('Hello')).toBeTruthy();
};
System Environment Info: System: OS: macOS 12.2 CPU: (12) x64 Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz Binaries: Node: 14.15.1 - /usr/local/bin/node npm: 6.14.8 - /usr/local/bin/npm Browsers: Chrome: 103.0.5060.114 Firefox: 99.0 Safari: 15.3 npmPackages: @storybook/addon-actions: ^6.5.9 => 6.5.9 @storybook/addon-essentials: ^6.5.9 => 6.5.9 @storybook/addon-interactions: ^6.5.9 => 6.5.9 @storybook/addon-links: ^6.5.9 => 6.5.9 @storybook/addon-postcss: ^2.0.0 => 2.0.0 @storybook/jest: 0.0.10 => 0.0.10 @storybook/testing-library: 0.0.13 => 0.0.13 @storybook/vue: ^6.5.9 => 6.5.9
Additional context hope this video helps. https://user-images.githubusercontent.com/37899422/177896735-ea92547a-a22f-49a4-b3a4-ed00d405055e.mov
Have the same issue. Not sure if helps, but I also have a global decorator to wrap the components in the storybook with vuetify stuff,
// preview.js
addDecorator(() => ({
vuetify: <config object>,
template: `
<v-app>
<v-main>
<v-container fluid>
<story />
</v-container>
</v-main>
</v-app>
`,
}));
// default preview.js stuffs...
// main.js
module.exports = {
stories: ['../stories/**/*.stories.mdx', '../stories/**/*.stories.@(js|jsx|ts|tsx)'],
addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'],
framework: '@storybook/vue',
features: {
interactionsDebugger: true, // 👈 Enable playback controls
},
webpackFinal: async (config) => {
config.module.rules.push({
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
include: path.resolve(__dirname, '../'),
});
return config;
},
};
system: mac m1 monterey 12.4 npm 8.10.0 node 16.14.2 brave v1.41.96
deps: @storybook/addon-actions: ^6.5.9 @storybook/addon-essentials: ^6.5.9 @storybook/addon-interactions: ^6.5.9 @storybook/addon-links: ^6.5.9 @storybook/builder-webpack4: ^6.5.9 @storybook/jest: ^0.0.10 @storybook/manager-webpack4: ^6.5.9 @storybook/test-runner: ^0.5.0 @storybook/testing-library: ^0.0.13 @storybook/vue: ^6.5.9
edit: add main and preview.js files
Same issue here, Vue 2. Using Nuxt (and nuxt/storybook module), no Vuetify. My config is very different from both of yours and I don't see any common factor except for Vue 2.
I tried some debugging, here's what I've found so far:
-
canvasElement
comes from here: https://github.com/storybookjs/storybook/blob/next/code/lib/preview-web/src/render/StoryRender.ts#L145 - There is a suspicious FIXME, but it's talking at a level I don't understand the system.
- On initial page load,
canvasElement
is set to an empty root div on the line above. - At the time this happens, that root div is in the DOM -- but it's still empty.
-
When the component is rendered on the page, that empty root div is replaced, but
canvasElement
is not reset. - Once
canvasElement
has been replaced (e.g. with another story), that same root divcanvasElement
is re-used, which is why it works when navigating between stories. - To figure out why the root div is replaced on page load, you'd have to dig into story rendering, and since that's decoupled from the code I've already dug into it will be hard for me to diagnose further without help.
- Likewise, perhaps it's intended behaviour that the div is replaced on initial load, and the bug is that
canvasElement
should be replaced when that happens. Again, that will require someone with knowledge of the system to look into it. - It's dispiriting that no one has looked into this in a month. Without a more experienced person helping with the above two points, it's likely I won't be able to adopt storybook interaction tests at all.
Hey everyone, thanks a lot for opening this issue and adding so much information here.
I can confirm this issue is happening in @storybook/vue
but not in @storybook/vue3
(works correctly there).
We'll have to investigate what's going on, so please bare with us :)
For anyone willing to dive deep into this (and we'd very much appreciate a contribution): My hunch is that there might be a slight problem in the way the Vue2 app (which wraps the story) is bootstrapped, and that might take a couple extra rendering cycles to get things going. Once the Vue2 app is bootstrapped, it's shared between story sessions, which is why it works when you visit from another story, but not when you deeplink to the story. cc @prashantpalikhe
What to do for now
We highly encourage users to use const canvas = within(canvasElement)
because it's a way for us to ensure that the play function will work correctly in all Storybook modes. However, it's totally possible to not use it, and instead use screen
, which, instead of searching for elements from within the div that wraps the story, it searches for elements in document.body
.
I'm sure you can get your interaction tests to work by using screen
instead, so here's an example to help visualize:
from:
import { within, userEvent } from '@storybook/testing-library';
LoggedIn.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const loginButton = await canvas.getByRole('button', { name: /Log in/i });
await userEvent.click(loginButton);
};
to:
import { within, userEvent } from '@storybook/testing-library';
import { screen, userEvent } from '@storybook/testing-library';
LoggedIn.play = async () => {
const loginButton = await screen.getByRole('button', { name: /Log in/i });
await userEvent.click(loginButton);
};
I hope this helps for now, while we find an ultimate solution. Thanks!
ps: you might get a warning in the logs saying "do not use screen", but please ignore them for now!
Thanks for the workaround @yannbf. I had thought of something similar but didn't know enough about supported queries to make it work. Putting the two together, I think I came up with an even better workaround: just fetch the canvasElement
manually.
const canvasElement = document.querySelector("#root") // instead of an arg
const canvas = within(canvasElement) // as usual
What I like especially about this one is it should be functionally equivalent (I assume) to expected behaviour, plus switching to the recommended approach (when fixed) will be trivial (remove a line, add an arg).
Thanks for the workaround @yannbf. I had thought of something similar but didn't know enough about supported queries to make it work. Putting the two together, I think I came up with an even better workaround: just fetch the
canvasElement
manually.const canvasElement = document.querySelector("#root") // instead of an arg const canvas = within(canvasElement) // as usual
What I like especially about this one is it should be functionally equivalent (I assume) to expected behaviour, plus switching to the recommended approach (when fixed) will be trivial (remove a line, add an arg).
Sounds perfect, @bmulholland ! Thanks for the suggestion!
@bmulholland Thanks for the suggestion, I have been pulling my hair out for the past couple days trying to solve this one until i found this thread. I ended up needing to add a timeout like this to get it working. Super hacky! But works for now.
const canvasElement = document.querySelector('#root')
await new Promise((resolve) => setTimeout(resolve, 0))
const canvas = within(canvasElement)
Just as a heads-up for users of (as of writing in alpha 7.0):
In storybook 7.0 we renamed this id to #storybook-root
.
Can confirm it only worked for me with the timeout solution by @fazulk
Just awaiting for the promise works for me, no need to look for element
Loading.play = async ({ canvasElement }) => {
// eslint-disable-next-line no-promise-executor-return
await new Promise(resolve => setTimeout(resolve, 0));
const canvas = within(canvasElement);
expect(canvas.getByText(props.loading.title)).toBeInTheDocument();
expect(canvas.getByTestId('spinner')).toBeInTheDocument();
expect(canvas.getByLabelText('default-progressbar')).toBeInTheDocument();
};
I am having the same issue with #storybook-root being empty with React 18 and Storybook 7. @alexandprivate your solution was close, but I wasn't able to resolve it with a 0 value. I had to implement at least a 1 second delay, but I was concerned it might be an issue of async that could vary. Figured if the addon author comes along he might prefer this solution:
play: async ({ canvasElement, args }) => {
await delayForCanvas(canvasElement);
const canvas = await within(canvasElement);
}
export const getTriangularNumber = (seed: number) => (seed * (seed + 1)) / 2;
export const delayForCanvas = async (canvasElement: HTMLElement) => {
let backOff = 0;
while (!canvasElement.innerHTML) {
backOff += 1;
if (backOff > 100) {
throw new Error(
"canvasElement.innerHTML did not populate after 100 increasing delays"
);
}
// eslint-disable-next-line no-loop-func
await new Promise((resolve) =>
setTimeout(resolve, getTriangularNumber(backOff))
);
}
};
I traced this back to the code/lib/preview-api/src/modules/store/csf/prepareStory.ts
s playFunction
, not addon-interactions. Hopefully a maintain can review and help move us all along.
Not sure if this is really resolved. We've hit this bug when using Vite - had to revert to using screen.
Was able to fix a similar issue by doing the following instead of using the canvasElement
const canvas = within(document.body);
await expect(canvas.getByTestId('modal__welcome__confirm')).toHaveTextContent('Button Text');