storybookjs / storybook

Storybook is the industry standard workshop for building, documenting, and testing UI components in isolation
https://storybook.js.org
MIT License
84.55k stars 9.3k forks source link

Story CSS bleeds into all other stories #16016

Open Bilge opened 3 years ago

Bilge commented 3 years ago

Describe the bug If any Story imports CSS, all other stories are forced to adopt the same CSS.

To Reproduce

// Story1.stories.ts

import 'foo.less';
// Story2.stories.ts

import 'bar.less';

Both Story1 and Story2 (and any/all other stories) have both foo.less and bar.less loaded.

System

Environment Info:

  System:
    OS: Windows 10 10.0.18363
    CPU: (8) x64 Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz
  Binaries:
    Node: 14.15.3 - C:\Program Files\nodejs\node.EXE
    Yarn: 1.22.10 - ~\AppData\Roaming\npm\yarn.CMD
    npm: 6.14.9 - C:\Program Files\nodejs\npm.CMD
  Browsers:
    Chrome: 93.0.4577.63
    Edge: Spartan (44.18362.1593.0)
  npmPackages:
    @storybook/addon-actions: ^6.3.7 => 6.3.8
    @storybook/addon-essentials: ^6.3.7 => 6.3.8
    @storybook/addon-links: ^6.3.7 => 6.3.8
    @storybook/html: ^6.3.7 => 6.3.8

Additional context Probably this only happens in the HTML "framework", as such a glaring bug would have been discovered before now otherwise.

CSS is loaded inline with style-loader.

// main.js

config.module.rules.push(
    {
        test: /\.less$/,
        use: [
            {loader: 'style-loader'},
            {loader: 'css-loader'},
            {loader: 'less-loader'},
        ],
    },
);
tmeasday commented 3 years ago

Hi @Bilge

  1. It is unusual to load CSS in story file. Is there a reason you are doing it there and not in the component file itself?

  2. This is not a bug, CSS imported via style-loader does not get removed when you browse away from a story, just as they do not when you browse away from a page in an app. This mirrors the way things work in an app, and if it behaved differently in SB it would likely lead to a bunch of confusion. In short in a SPA it is asking for a bad time to have global styles that are conditionally loaded.

I am guessing now but I am thinking maybe the app in question is not a SPA and you have global styles that you want to apply for one page but not another; and you get away with this because of the full-page refresh involved in navigating around?

If that's the case, then this is not the use case SB was designed for. However we can probably figure out a solution using a decorator.

Bilge commented 3 years ago

Yes, I have never worked on an SPA. All of your assumptions are correct.

dwhieb commented 3 years ago

I'm working on an SPA using vanilla JS, and using the HTML framework of Storybook. My styles are not always loaded in the component itself. So I'd also like to be able to import the styles for a single component into a story without those styles bleeding into other stories.

tmeasday commented 3 years ago

Yes, I have never worked on an SPA. All of your assumptions are correct.

@Bilge so the simplest solution that is probably closest to the non-SPA experience would be to throw in a location.reload() in a decorator. Maybe something as simple as:

let storyId;
const reloadDecorator = (storyFn, context) => {
  if (storyId && context.id !== storyId) {
    document.location.reload();
  }

  storyId = context.id;
  return storyFn();
}

So I'd also like to be able to import the styles for a single component into a story without those styles bleeding into other stories.

@dwhieb I am curious as to how you import styles in your app and then "un-import" them when you browse to a different page. Can you tell me a bit more about that?

dwhieb commented 3 years ago

@tmeasday All the styles needed for individual pages are scoped to that page, and that page's CSS is loaded dynamically when the page loads.

That said, I think my actual issue was that in my LESS code I was often applying a class to a certain kind of element (e.g. h1 { .header; }), and this worked in my app because the <h1> styling was scoped to the current page. But of course doing this in Storybook meant that the styling for <h1> bled through to other stories. To fix this I've just added explicit classes to elements where necessary now (<h1 class=header>), and removed any statements like h1 { .header; } from my styling.

I was basically trying to avoid cluttering my HTML with CSS classes and it backfired 😬

Bilge commented 3 years ago

This should really be tagged with bug, in case tags matter.

Bilge commented 1 year ago

I wish we had a solution for this that does not require modifying every single story to do a location reload. This should be supported by the system by some mechanism. This issue has stopped me from using Storybook since I filed it over a year ago, which is very disappointing.

tmeasday commented 1 year ago

@Bilge the decorator I recommended above would just be defined once in preview.js. I'm not sure you'd need to change every story.

Bilge commented 1 year ago

@tmeasday I see. Nevertheless, wouldn't your proposed workaround also change the behaviour of Storybook in that it would forget state when switching between stories, unlike normal, where it actually remembers the state of each story as you change controls and switch between stories?

tmeasday commented 1 year ago

@Bilge it might work OK as the (args) state is recorded in the URL and usually re-inits OK. I'm not quite sure how we could make it work that CSS needs to be reset each time you change story otherwise though?

yevgeni-accessibe commented 1 year ago

i dont know if our use cases match, but i solved this issue using raw-loader and sass-loader:

webpackFinal: async (config) => {
    const cssRuleIndex = config.module.rules.findIndex((rule) => rule.test.test(".scss"));

    config.module.rules[cssRuleIndex].use = ["raw-loader", "sass-loader"];

    return config;
  }
daniele-zurico commented 1 year ago

Hi all... I'm sorry but I'm going through every thread however I still didn't find any workable solution yet. Am I missing something?

daniele-zurico commented 1 year ago

Hi @tmeasday I was giving a try to your solution:

let storyId;
const reloadDecorator = (storyFn, context) => {
  if (storyId && context.id !== storyId) {
    document.location.reload();
  }

  storyId = context.id;
  return storyFn();
}

as global decorator in the preview.js. This is my preview.js atm:

// https://storybook.js.org/docs/react/writing-stories/parameters#global-parameters
import '../stories/govUkStyle.css';

const tokenContext = require.context(
  '!!raw-loader!../src',
  true,
  /.\.(css|less|scss|svg)$/
);

const tokenFiles = tokenContext.keys().map(function (filename) {
  return { filename: filename, content: tokenContext(filename).default };
});

export const parameters = {
  // https://storybook.js.org/docs/react/essentials/actions#automatically-matching-args
  actions: { argTypesRegex: '^on.*' },
  designToken: {
    files: tokenFiles
  },
  options: {
    storySort: {
      order: ['DCXLibrary', 
      [
        'Introduction', 
        'Utils', 
        'Form',['Select', ['documentation', 'live', 'Default', 'Design-System','Class-Based']],
        'CopyToClipboard', 
        'Details', 
        'Tabs', 
        'Table', 
        'Changelog'
      ]
    ]
    },
  }
};
let storyId;
export const decorators = [
  (storyFn, context) => {
    if (storyId && context.id !== storyId) {
      document.location.reload();
    }
    console.log('is it called???');
    storyId = context.id;
    return storyFn();
  }
];

It works perfectly fine in the canvas section but as soon as you go in the Docs tab I got an infinite loop to reload

Screenshot 2023-03-20 at 15 04 21
tmeasday commented 1 year ago

Hmm, yes I can see that would be a problem. I suppose for Docs pages you would probably want to check the context.title (ie the component) rather than the context.id.

Keep in mind a couple restrictions with that:

  1. Clearly the different stories loaded in the docs page would need to be compatible with each other in terms of global CSS.
  2. You would also need to only load a single component's stories on any single docs page. If you are using just autodocs, that's fine.

An alternative would be to ensure you iframe the docs stories, via parameters.story.inline = false.

daniele-zurico commented 1 year ago

so just for other in case they have the same challenge... this is how I fix it: in preview.js:

//It will allow to refresh the iframe all the time you move from one story to another - buggy ... it doesn't work
let storyId;
let storyTitle;
export const decorators = [
  (storyFn, context) => {
    console.log('context.title:',context.title);
    console.log('storyTitle:',storyTitle);
    if (storyTitle && context.title !== storyTitle) {
      document.location.reload();
      console.log('first')
    } else if(storyId && context.id !== storyId && context.title !== storyTitle) {
      document.location.reload();
      console.log('second')
    }
    storyId = context.id;
    storyTitle = context.title;
    return storyFn();
  }
];

I do appreciate is not the best solution but at least it works for me. @tmeasday I still think that storybook should offer this opportunity. Our use case is pretty valid... I'm happy to provide more informations if needed

tmeasday commented 1 year ago

OK, let's keep this open and report back if you see further problems @daniele-zurico. I guess having a feature flag for "story isolation mode" or similar might make sense for this use case. Can folks keep upvoting the top comment on this ticket to get it on the radar?

The more I think about it, the more I think just rendering docs stories in an iframe would make sense in such a mode btw.

alt-jero commented 1 year ago

I'm using it with svelte-kit, whose component css is ostensibly isolated per component automatically, but in storybook it's not. I figured this out when I copied the example Button to another directory to play with it, while still having the original as a reference. I cleared out the CSS after a while, and the button was still styled as the storybook default button.

Doing so in the opposite direction, that is - clearing out the css file for the original button and leaving it for my copy, does de-style one of the buttons.

The fact that this happens, and also is dependent on however storybook decides to load components (I'm guessing breadth-first in directory listing order) makes for a very non-isolated environment for testing.

The reason this should be a bug, and not just a support question, is that storybook's whole idea is to give a place and a method for developing components in isolation before combining them - something which is thusly not the actual case.

bloqhead commented 1 year ago

So this isn't the most ideal approach but it works for my unique scenario.

First, some context. I'm using Storybook as a tool to preview some really simple, static HTML files that we crank out often. I wanted a way to view kind of a historical list of designs so stakeholders can scroll through and review them at their leisure. So no need for fancy things I've used in the past like args, props, etc. Very basic static HTML. Unfortunately in my use case, we have a lot of styles applied to things like header, section, etc. which leads to style leaks everywhere when each component is importing its own styles.

The first thing I did was look into how to build custom decorators. I created one that wraps my story in a container that has the componentId as the wrapper ID:

import React from 'react';

export const storyDecorator = (storyFn, context) => {
  const storyId = context.componentId;

  return (
    <div id={storyId}>
      {storyFn()}
    </div>
  );
};

Then I import it into my .storybook/preview.js file:

/** @type { import('@storybook/react').Preview } */

import { storyDecorator } from "./customDecorators";

const preview = {
  parameters: {
    actions: { argTypesRegex: "^on[A-Z].*" },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
  },
};

export const decorators = [storyDecorator];

export default preview;

Then in my imported component stylesheets, I wrap my styles in that ID. So something like:

#my-cool-story { /* all scoped styles here */ }

In my setup, I have a custom bash script that lets a user scaffold out a new page quickly. In the process, I have a command that runs sed to do a find-and-replace of the scoped container ID in my boilerplate SCSS file. The replaced ID also has a unique datetime string appended to it so we never have style or naming collisions. So every new story comes with a unique ID wrapper in both the component itself, and the SCSS file.

Like I said, not ideal, but it got me across the finish line. This is a unique case because we don't have the luxury of CSS scoping like we do in Vue and React components. I considered using CSS modules too, but not everything has an ID or class applied to it (and we can't modify the HTML structure at our leisure).

Hope this helps someone!

Bilge commented 1 year ago

@bloqhead That sounds truly, truly awful. Thanks for sharing.

bloqhead commented 1 year ago

@Bilge it is most definitely not ideal, haha. Fortunately, there isn't anything we have to modify outside of the CSS. So while it would otherwise be a brittle setup, it works for this weird use case.

herrKlein commented 1 year ago

Really needing this. We make some 'headless' compononents, like in 'headless ui' without styling. The different stories display the same component but a different stylesheet, or modified stylesheet. Also the CSS variables of one imported css in one story stay in the browser, so it builds up all css variables in the css inspector from previous stories

herrKlein commented 1 year ago

This is how we fixed it:

We use a decorator which encapsulates the story in an empty elements shadowRoot, called : storybook-shadow-root

the decorator

export function withShadowRoot(storyFn: StoryFn, csss: string = '') {
  const element = document.createElement('storybook-shadow-root');
  const shadow = element.attachShadow({ mode: 'open' });
  const sheet = new CSSStyleSheet();
  sheet.replaceSync(csss);
  shadow.adoptedStyleSheets = [sheet];
  element.appendChild(shadow);
  render(storyFn(), this.shadow);
  return html`${element}`;
}

you can use it like this:

import story_style from 'styles/mystory.css?inline';

export default {
  component: 'render-in-shadow',
  decorators: [story => withShadowRoot(story, story_style)],
};
Chofito commented 1 year ago

If someone wants a React solution here you are:

Create a ShadowRootContainer container that renders a children component inside a shadowroot with a styles tag that includes the styles you need.

import { useLayoutEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';

export type ShadowRootContainerProps = {
  children: JSX.Element | JSX.Element[];
  css: string;
}; 

const ShadowRootContainer = ({
  children,
  css,
}: ShadowRootContainerProps) => {
  const containerRef = useRef(null);
  const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null);

  useLayoutEffect(() => {
    if (containerRef.current) {
      const container = containerRef.current as HTMLElement;
      const shadowRootElement = shadowRoot || container.attachShadow({ mode: 'open' });
      const style = document.createElement('style');
      const existingStyle = shadowRootElement.querySelector('style');

      if (existingStyle) {
        shadowRootElement.removeChild(existingStyle);
      }

      style.innerHTML = css;

      shadowRootElement.appendChild(style);

      setShadowRoot(shadowRootElement);
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [children, css]);

  return (
    <div ref={containerRef}>
      {
        shadowRoot && ReactDOM.createPortal(
          children,
          shadowRoot,
        )
      }
    </div>
  );
};

export default ShadowRootContainer;

Then you can use within a custom render function on storybook or you can use a decorator like this

import { StoryFn } from '@storybook/react';

import ShadowRootContainer from './ShadowRootContainer';

const withShadowRoot = (css: string) => (StoryFn: StoryFn) => (
  <ShadowRootContainer css={css}>
    <StoryFn />
  </ShadowRootContainer>
);

export default withShadowRoot;

And your decorator will look like this:

import styles from './NavigationBarLines.scss?inline'; // use any import you need

decorators: [withShadowRoot(styles.toString())], // You can change it to use any other type of styling solution
herrKlein commented 1 year ago

@Chofito and another solution:

export function reactStory(Story, csss: string = '') {
  const container = document.createElement('div');
  const shadow = container.attachShadow({ mode: "open" });
  const sheet = new CSSStyleSheet();
  sheet.replaceSync(csss);
  shadow.adoptedStyleSheets = [sheet];
  createRoot(shadow).render(<Story />);
  return container;
}
import { reactStory } from '../../../.storybook/decorators';
import style from '@component/styles.css?inline';
const meta = {
  decorators: [story => reactStory(story, style)],
};

this also works if you have a storybookwebcomponent repository and want to render react components in stories. ( because of testing react wrappers we have created )

daniele-zurico commented 12 months ago

@tmeasday I can see that everyone is trying to implement a custom solution here so it's quite spread as "challenge". I was wondering if the Storybook team is taking in consideration to improve/add this feature and by when

CollinHerber commented 2 months ago

Just ran into this myself. I for example am using the same storybook application for 2 design systems and the styles from design system 1 are bleeding into design system 2 and I have not found a clean way to circumvent this. It would be good to prevent the styles bleeding into stories that it's not relevant to.

kfirprods commented 1 week ago

I ran into this problem as well and ended up writing a decorator that simply overrides CSS variables (without any fancy shadowRoot etc).

However, I wrote a Medium story about this issue and covered 3 possible workarounds (including the shadowRoot solution that was suggested here), so if you're trying to show off different themes in different stories, have a read here: Medium Story: 3 ways to show off your themeable React components in Storybook