JesusTheHun / storybook-addon-remix-react-router

Use your app router in your stories. A decorator made for Remix React Router and Storybook
Apache License 2.0
44 stars 10 forks source link

`useArgs` in render function breaks Storybook #67

Open ai212983 opened 1 week ago

ai212983 commented 1 week ago

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>
    );
    },
  3. 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
JesusTheHun commented 1 week ago

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' }
}
ai212983 commented 1 week ago

@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.

JesusTheHun commented 1 week ago

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

ai212983 commented 1 week ago

@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}
            />
    );
};
JesusTheHun commented 1 week ago

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 !

ai212983 commented 1 week ago

...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).

JesusTheHun commented 1 week ago

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 ;)

ai212983 commented 6 days ago

@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 ;)

JesusTheHun commented 6 days ago

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

ai212983 commented 5 days ago

@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.

JesusTheHun commented 4 days ago

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

ai212983 commented 4 days ago

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

JesusTheHun commented 2 days ago

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