storybook-addon-remix-react-router icon indicating copy to clipboard operation
storybook-addon-remix-react-router copied to clipboard

`useArgs` in render function breaks Storybook

Open ai212983 opened this issue 1 year ago • 16 comments

Storybook crashes with Error: Storybook preview hooks can only be called inside decorators and story functions. when attempting to use the useArgs hook with storybook-addon-remix-react-router.

To Reproduce

  1. Open the MultipleStoryInjection story from Basic stories.
  2. Modify the render function to include the useArgs hook:
  render: () => {
    const location = useLocation();
    const [updateArgs] = useArgs();
    return (
      <div>
        <p>{location.pathname}</p>
        <Link to={'/login'}>Login</Link> | <Link to={'/signup'}>Sign Up</Link>
      </div>
    );
  },
  1. If the story doesn't crash immediately, click on any link in the rendered component.

Additional context

Although SB hooks are not permitted directly inside components, they are allowed within render functions. The inability to use useArgs prevents testing configurable component behavior in response to navigation events.

In my specific case, it affects the useBlocker hook in my custom form component.

Environment

npx sb info

Storybook Environment Info:
(node:31405) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)

  System:
    OS: macOS 14.5
    CPU: (12) arm64 Apple M2 Max
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 22.2.0 - /opt/homebrew/bin/node
    Yarn: 1.22.19 - /opt/homebrew/bin/yarn
    npm: 10.7.0 - /opt/homebrew/bin/npm <----- active
  Browsers:
    Chrome: 126.0.6478.127
    Safari: 17.5
  npmPackages:
    @storybook/addon-essentials: ^8.1.10 => 8.1.10
    @storybook/addon-interactions: ^8.1.10 => 8.1.10
    @storybook/addon-links: ^8.1.10 => 8.1.10
    @storybook/blocks: ^8.1.10 => 8.1.10
    @storybook/react: ^8.1.10 => 8.1.10
    @storybook/react-vite: ^8.1.10 => 8.1.10
    @storybook/test: ^8.1.10 => 8.1.10
    eslint-plugin-storybook: ^0.8.0 => 0.8.0
    storybook: ^8.1.10 => 8.1.10
    storybook-addon-remix-react-router: ^3.0.0 => 3.0.0

ai212983 avatar Jun 28 '24 07:06 ai212983

Hello @ai212983 👋

Sadly this is true with any decorator. In the example below, as soon as you click on the button to increment the value, it will break.

export const RawStory = {
  decorators: [
    (Story: any) => {
      return (
        <section>
          decorated:
          <Story />
        </section>
      );
    },
  ],
  render: (args: { foo: string }) => {
    const [updateArgs] = useArgs();
    const [count, setCount] = useState(0);

    return (
      <>
        <h1>Args</h1>
        <p>{JSON.stringify(args)}</p>

        <button onClick={() => setCount((count) => count + 1)}>Increase</button>
        <div role={'status'}>{count}</div>
      </>
    );
  },
  args: {
    foo: 'bar',
  },
};

The reason for that is SB clears the storyContext as soon as the story is rendered. So during the first render it works, but on the next render, it breaks. This has already been reported in #33, and sadly things have not changed on the SB side.

You can see https://github.com/storybookjs/storybook/issues/12006 .

Now, if what you want is only to read the story args, you should known that the first argument received by the story function is the args object.

export const RawStory = {
 render: ({ foo }) => { // <== the args are accessible here
    const location = useLocation();
    const [updateArgs] = useArgs();
    return (
      <div>
        <p>{location.pathname}</p>
        <Link to={'/login'}>Login</Link> | <Link to={'/signup'}>Sign Up</Link>
      </div>
    );
  },
  args: { foo: 'bar' }
}

JesusTheHun avatar Jun 28 '24 12:06 JesusTheHun

@JesusTheHun Hey there! :)

You're right, the story context does reset on every re-render. I'm using useArgs to restore my component state, so it kinda works.

I did see both of those tickets but thought maybe there was some way to work around this.

Thanks so much for explaining. I guess you can go ahead and close the ticket now.

ai212983 avatar Jun 28 '24 12:06 ai212983

I'm using useArgs to restore my component state What do you mean exactly ?

JesusTheHun avatar Jun 28 '24 12:06 JesusTheHun

@JesusTheHun well, the usual. Something like this:

const defaultRender = function Render(args: any) {
    const [{ onChange }, updateArgs] = useArgs();
    return (
            <ComboList
                {...args}
                onChange={onChange
                    ? (items: ListItems[]) => updateArgs({ items })
                    : undefined}
            />
    );
};

ai212983 avatar Jun 28 '24 13:06 ai212983

well, the usual

@ai212983 I've never used it like that. That's not a bad idea, but yeah, it cannot work. A dedicated decorator with a context and hook would work though !

JesusTheHun avatar Jun 28 '24 13:06 JesusTheHun

...but yeah, it cannot work.

@JesusTheHun Not sure if I follow you here :) The example above works fine for me. items are part of the args:

export const DefaultUsage: Story = {
    // @ts-ignore
    args: {
        // @ts-ignore
        onChange: false,
        items: generateItems(5),
        layers: generateLayerInfos(50),
    },
    render: defaultRender,
};

they are passed to my component by the render function. updateArgs in the example above works as a state setter.

If we can use useArgs inside the decorator, probably it is possible to parametrize withRouter so it returns useArgs? Not sure how decorators work though (maybe I should lol).

ai212983 avatar Jun 28 '24 14:06 ai212983

The example above works fine for me

Yes, but it doesn't use a decorator.

If we can use useArgs inside the decorator, probably it is possible to parametrize withRouter so it returns useArgs? Not sure how decorators work though (maybe I should lol).

We could do some reference passing, but that's a bit dirty. Maybe I'll create such decorator as a standalone package, it should be straight forward. Maybe next week, I'll keep you posted ;)

JesusTheHun avatar Jun 28 '24 15:06 JesusTheHun

@JesusTheHun Actually it does. Here's the full code for ComboList.stories.tsx:

import "../styles/tailwind.css";
import "../styles/index.scss";
import { ComboList } from "@/Keyboard/Combos/ComboList";
import { ZMKCombo } from "@/localResources";
import { SearchContext } from "@/providers";
import { faker } from "@faker-js/faker";
import { useArgs } from "@storybook/preview-api";
import { Meta, StoryObj } from "@storybook/react";
import { createFnMapping, createTestSearchContext, generateItemDescription, generateLayerInfos } from "./common";

const meta = {
    title: "Keyboard/Combo/List",
    component: ComboList,
    parameters: {
        layout: "padded",
    },
    argTypes: {
        onItemUpdate: {
            control: "boolean",
            name: "Read Only",
            mapping: createFnMapping(false),
        },
        onItemDelete: { table: { disable: true } },
        onItemCreate: { table: { disable: true } },
    },
    decorators: [
        (Story) => (
            <div className="tailwind" style={{ height: "calc(100vh - 3rem)" }}>
                <div className="h-full">
                    <Story />
                </div>
            </div>
        ),
    ],
} satisfies Meta<typeof ComboList>;

// noinspection JSUnusedGlobalSymbols
export default meta;
type Story = StoryObj<typeof meta>;

const searchContext = createTestSearchContext(20);

const defaultRender = function Render(args: any) {
    const [{ items, onItemUpdate }, updateArgs] = useArgs();
    const setItems = (items: ZMKCombo[]) => {
        updateArgs({ items });
    };

    return (
        <SearchContext.Provider value={searchContext}>
            <ComboList
                {...args}
                onItemDelete={(name: string) => {
                    setItems(items.filter((m: ZMKCombo) => m.name !== name));
                }}
                onItemUpdate={onItemUpdate
                    ? (name: string, item: ZMKCombo) => {
                        setItems(items.map((m: ZMKCombo) => (m.name === name ? item : m)));
                    }
                    : undefined}
                onsItemCreate={(item: ZMKCombo) => {
                    setItems([...items, item]);
                }}
            />
        </SearchContext.Provider>
    );
};

// noinspection JSUnusedGlobalSymbols
export const DefaultUsage: Story = {
    // @ts-ignore
    args: {
        items: generateItems(5),
        layers: generateLayerInfos(50),
        // @ts-ignore
        onItemUpdate: false,
    },
    render: defaultRender,
};

There's no any logic in this decorator, but still. I've ended with special render function which uses useState for state management. Linter is complaining, but it works. Can't wait to test a custom decorator though ;)

ai212983 avatar Jul 01 '24 08:07 ai212983

@ai212983 can you provide a repro inside a stackblitz for example ? or a git repo

JesusTheHun avatar Jul 01 '24 10:07 JesusTheHun

@JesusTheHun Here you go - also I've updated your project so Stackblitz displays Stories by default.

Navigate to Demo/useArgs/Default Usage story and click increase.

ai212983 avatar Jul 01 '24 16:07 ai212983

@ai212983 yes it does work in this case, because the args update do not trigger the decorator function to run again.

JesusTheHun avatar Jul 03 '24 09:07 JesusTheHun

@JesusTheHun Not exactly so. I've updated the example, you can trigger re-run decorator function with local state counter.

ai212983 avatar Jul 03 '24 09:07 ai212983

@ai212983 Indeed. I've also tried to create an addon to have a custom hook. It's harder than it looks like !

JesusTheHun avatar Jul 05 '24 09:07 JesusTheHun

I hope I can use useArgs() and useLocation() together.

    const { pathname} = useLocation();
    const [updateArgs] = useArgs();

Any workaround?

helloint avatar Aug 20 '24 15:08 helloint

@helloint well really it's a limitation of Storybook addons in React. It's not specific to this addon. I've started to work on something to workaround it but it's a mess. I really hope SB will fix this on their end.

JesusTheHun avatar Aug 20 '24 16:08 JesusTheHun

I have same problem. On first render i catch error and after F5 it can render story

popuguytheparrot avatar Mar 13 '25 14:03 popuguytheparrot