Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bug]: React component seems to be mounted twice when a story render function uses the args parameter #23921

Open
mirobossert opened this issue Aug 23, 2023 · 10 comments

Comments

@mirobossert
Copy link

Describe the bug

When using the args parameter in the render function of a Storybook story, the React component appears to be mounted twice. This behavior is causing issues, especially when using a custom hook directly in the story.

To Reproduce

  1. Go to the provided StackBlitz link.
  2. Open the the Button stories Working and Not Working and observe the browser console to see the different behaviour of the two stories.
  • Working: The hook in the render function works as expected, it logs a message to the console on each second click on the button
  • Not Working: The component seems to get mounted twice. One instance seems to behave correctly the other one does not update it's state. As a result it logs once on each click and twice on each second click. The only difference between the two stories seems to be the args parameter gets passed into the render function.

https://stackblitz.com/edit/github-56csk3?file=stories%2FButton.stories.tsx

System

System:
    OS: Linux 5.0 undefined
    CPU: (8) x64 Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
  Binaries:
    Node: 16.20.0 - /usr/local/bin/node
    Yarn: 1.22.19 - /usr/local/bin/yarn
    npm: 9.4.2 - /usr/local/bin/npm
  npmPackages:
    @storybook/addon-essentials: ^7.4.0-alpha.0 => 7.4.0-alpha.0 
    @storybook/addon-interactions: ^7.4.0-alpha.0 => 7.4.0-alpha.0 
    @storybook/addon-links: ^7.4.0-alpha.0 => 7.4.0-alpha.0 
    @storybook/addon-onboarding: ^1.0.8 => 1.0.8 
    @storybook/blocks: ^7.4.0-alpha.0 => 7.4.0-alpha.0 
    @storybook/nextjs: ^7.4.0-alpha.0 => 7.4.0-alpha.0 
    @storybook/react: ^7.4.0-alpha.0 => 7.4.0-alpha.0 
    @storybook/testing-library: ^0.2.0 => 0.2.0

Additional context

I understand that the observed behavior might be intentional or could potentially be influenced by the implementation of the custom hook. While I've tried to isolate the issue to the best of my ability, it's possible that there are factors I'm not aware of that could contribute to this behaviour.

Any assistance, guidance, or ideas on how to approach this issue would be highly valued. Thank you in advance for your help.

@cbrianball
Copy link

I have run into this issue as well. It looks like the function arguments don't even need to be used, by the fact that they are defined on the function is enough to cause it to render twice.

I have found a workaround, if you put all of the function arguments into a single rest argument, the story appears to only render once. It's not pretty, but it works for me.

This causes the story to render twice:

export const MyStory = (args) => {...};

But this will cause the story to only render once (as expected):

export const MyStory = (...[args]) => { ... }

@Fi1osof
Copy link

Fi1osof commented Jun 30, 2024

Confirm that args causes mounting twice. And each time args - same object.

let argsMemorized: object | undefined = undefined

const CheckedIcon: React.FC = (args) => {
  if (argsMemorized === undefined) {
    console.log("no argsMemorized")
    argsMemorized = args
  }

  useEffect(() => {
    console.log("CheckedIcon mounted args", args)
    console.log(
      "CheckedIcon mounted args === argsMemorized",
      args === argsMemorized
    )

    return () => {
      console.log("CheckedIcon unmounted")
    }
  }, [args])

  return <Renderer src={checkedIcon.src} />
}

Output:

no argsMemorized
(index):75 Renderer mounted
(index):107 CheckedIcon mounted args {}
(index):108 CheckedIcon mounted args === argsMemorized true
(index):107 CheckedIcon mounted args {}
(index):108 CheckedIcon mounted args === argsMemorized true

And confirm, this workaround works.

@ndelangen
Copy link
Member

I plan to take a look at this.

@ndelangen
Copy link
Member

@mirobossert I suspect it's react in strict-mode that's acting differently based on wether args is used in the custom render function.

@vanessayuenn vanessayuenn assigned JReinhold and unassigned ndelangen Aug 20, 2024
@JReinhold
Copy link
Contributor

I've investigated this and concluded that the source of this bug is excludeDecorators.

I suspect it's react in strict-mode that's acting differently based on wether args is used in the custom render function.

this isn't the case @ndelangen, we're not rendering in strict mode, that is something the user explicitly adds to the story when needed.

The double rendering happens because of this logic:

// Exclude decorators from source code snippet by default
const storyJsx = context?.parameters.docs?.source?.excludeDecorators
? (context.originalStoryFn as ArgsStoryFn<ReactRenderer>)(context.args, context)
: story;

Basically, when we want to exclude decorators from the source generation (for docs), we first execute the originalStoryFn() to get the undecorated JSX output. This is an execution of the story-function separately from the story rendering itself, which is why it "renders" twice.

(Interestingly this doesn't just impact the initial rendering - as in the reproduction above, when you click the button to flip the state, that also logs twice, even though only one is rendered. My guess is that executing the same instance of a React component multiple times isn't really compatible with React.)

The reason this seems related to args, is because when a story has args it gets the __isArgsStory parameter set (via static analysis), which controls if rendering-for-source-output should be skipped or not:

export const skipJsxRender = (context: StoryContext<ReactRenderer>) => {
const sourceParams = context?.parameters.docs?.source;
const isArgsStory = context?.parameters.__isArgsStory;
// always render if the user forces it
if (sourceParams?.type === SourceType.DYNAMIC) {
return false;
}
// never render if the user is forcing the block to render code, or
// if the user provides code, or if it's not an args story.
return !isArgsStory || sourceParams?.code || sourceParams?.type === SourceType.CODE;
};

I've confirmed this by setting parameters.docs.source.code = 'something static' which also skips source-rendering as you can see above, and that too "fixes" the issue.

Finally, setting parameters.docs.source.excludeDecorators = false also fixes the issue.

The logic that executes originalStoryFn() was introduced in Storybook 6.3 via #14652. However, excludeDecorators have historically been false by default, but it became true by default for Next.js projects in Storybook 7.0.0-beta-48 via #21029

@shilman @tmeasday do you have any good ideas for how to fix this, beyond completely reworking our source-generation logic?

@tmeasday
Copy link
Member

Great work figuring that out @JReinhold! I want to say I remember something like this happening once before and us reordering decorators in order to not have to enable excludeDecorators for React. Does that ring any bells @shilman?

@JReinhold
Copy link
Contributor

JReinhold commented Aug 22, 2024

Great work figuring that out @JReinhold! I want to say I remember something like this happening once before and us reordering decorators in order to not have to enable excludeDecorators for React. Does that ring any bells @shilman?

@tmeasday here's the paper trail I've found:

  1. reorder decorators in Reverse order of project decorators #21182 by @valentinpalkovic
  2. default to exclude decorators everywhere in Docs: Exclude decorators by default from source #21722 by @tmeasday
  3. open issue [Bug]: 7.0.0-rc.9 Decorators are broken in custom render components #21900 about that change with "It seems like decorators are no longer used in some situations, depending on whether arguments are provided to a custom render function" (interesting, huh?)
  4. revert the default exclusion of decorators in Addon-docs: Include decorators by default in source decorators #21902

I find this specific change by @tmeasday interesting:

https://github.com/storybookjs/storybook/pull/21722/files#diff-fa2456068b391a5bc726772d8fffba63030f53d2e8b486c21ba354b584edd3cdL32

Where we don't execute story anymore but just pass as-is - could we do the same with originalStoryFn or is that a totally different function signature?

@tmeasday
Copy link
Member

tmeasday commented Aug 22, 2024

@JReinhold I think that change doesn't quite do that, I think it actually always calls storyFn() in those two renderers, I think this is just a bug fix, previously those two renderers were broken when you excluded decorators (that's why @shilman didn't revert that part).

Thank you for the leg work so I think the history here is:

  1. We added some decorators and reordered in NextJS so some get between the source decorator and the story
  2. This meant the source block was useless in NextJS as it rendered the decorator
  3. We turned on exclude decorators everywhere by default to avoid this, but this had side-effects like this issue.
  4. We reverted that and just did it for NextJS.

I wonder if ultimately we need a different approach for excludeDecorators maybe? I'm not sure. I think we are stuck between a rock and a hard place here for NextJS with the current approach.

A hacky solution we had considered was some mechanism to "force" the source decorator to be the last (or would you call it first) one, somehow.

@JReinhold
Copy link
Contributor

A hacky solution we had considered was some mechanism to "force" the source decorator to be the last (or would you call it first) one, somehow.

I can see that working, but do we then put it as the innermost decorator to only show the pure story, or should it still encapsulate the user-defined decorators in the project, meta and story-level? I'm unsure what the desired behavior is.

Perhaps we acknowledge that if you put something in a decorator, you don't want it in the source view - if you want a wrapper displayed in the source view, it must go in render?

@tmeasday
Copy link
Member

I think that sounds right

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Empathy Backlog
Development

No branches or pull requests

7 participants