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

argTypes table is not generated for components with React.forwardRef and index type in their interface (with typescript) #15334

Open
JoyTailor-1775 opened this issue Jun 23, 2021 · 12 comments

Comments

@JoyTailor-1775
Copy link

Describe the bug
Hello, dear Storybook dev team, please take my sincere appreciation and gratitude toward the top-notch tool that you've created.

I have started using Storybook recently in my existing project and bumped into this problem right away, with the very first component. Frankly speaking, I hesitate regarding the importance of this bug, but I personally have spent around 10h trying to figure out the problem and would like to save some time for anyone else who could stumble in the same situation.

The problem is that if you have a React component wrapped in React.forwardRef and with index type in its interface, the arcTypes table in Storybook won't be rendered. You may see the screenshots of my code below. I've also created a repo in my GitHub, so feel free to fork it.

So far, I've figured out that the issue happens only with React.forwardRef, but it's possible that some other react utilities may have the same effect.

As a workaround I also have found that if you will use React.FC<ComponentProps> typing for your component, this will remove the problem, even if the index type is still there.

To Reproduce

  1. Clone or fork with repository - https://github.com/JoyTailor-1775/storybook-bug

  2. Run npm install and npm run storybook

  3. See that no doc is created for the Button component;

  4. Go to src/types/Button.ts and uncomment the 54th line (with index type);

  5. Restart the project and see the doc rendered;

System
System:
OS: macOS 11.2.3
CPU: (8) x64 Intel(R) Core(TM) i7-4770HQ CPU @ 2.20GHz
Binaries:
Node: 14.16.1 - /usr/local/bin/node
Yarn: 1.22.4 - /usr/local/bin/yarn
npm: 7.18.1 - /usr/local/bin/npm
Browsers:
Chrome: 91.0.4472.114
Safari: 14.0.3
npmPackages:
@storybook/addon-actions: ^6.3.0-rc.11 => 6.3.0-rc.11
@storybook/addon-essentials: ^6.3.0-rc.11 => 6.3.0-rc.11
@storybook/addon-links: ^6.3.0-rc.11 => 6.3.0-rc.11
@storybook/builder-webpack5: ^6.3.0-rc.11 => 6.3.0-rc.11
@storybook/manager-webpack5: ^6.3.0-alpha.41 => 6.3.0-rc.11
@storybook/react: ^6.3.0-rc.11 => 6.3.0-rc.11

Additional context

Screenshot 2021-06-23 at 17 12 58

Screenshot 2021-06-23 at 17 20 57

@pahan35
Copy link

pahan35 commented Oct 15, 2021

Same for me.

There are empty props from such components.

Actually, it looks like there is something related to webpack. It looks like __webpack_require__() returns it without props.

req(filename) here on the screen is actually __webpack_require__(id) call.
image
image

It seems like it's either something to fix from webpack side, or something to fix on build toolchain side from storybook.

@shilman any idea where the problem is? If yes, please point me, and I'll try to help you fix it.

@pahan35
Copy link

pahan35 commented Oct 15, 2021

Potentially, it's related to https://www.npmjs.com/package/babel-plugin-react-docgen https://github.com/storybookjs/babel-plugin-react-docgen

@honohunter
Copy link

I am facing the same issue,

@JoyTailor-1775 can you please share the workaround that you found?

@tupton
Copy link

tupton commented Dec 16, 2021

@honohunter I'm not sure this is the exact same workaround that @JoyTailor-1775 found, but I have a workaround that sounds similar. Assuming you have a component called Button that uses React.forwardRef:

// Button.stories.tsx
import Button, {ButtonPropsType} from './Button';

// eslint-disable-next-line react/jsx-props-no-spreading
export const ButtonProps: React.FC<ButtonPropsType> = (props) => <Button {...props} />;

//👇 This default export determines where your story goes in the story list
export default {
  /* 👇 The title prop is optional.
   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
   * to learn how to generate automatic titles
   */
  title: 'Components/Button',
  component: ButtonProps,
  args: {
    children: 'Button'
  }
} as ComponentMeta<typeof ButtonProps>;

A few things to note here:

  • Import both the component and the props type PropsType from the component file.
  • Create a new component that wraps the storied component in React.FC<PropsType>.
  • Make sure this is the first named export.
  • Pass this component to the component property of the default export, as well as the type generic for ComponentMeta.

What this gets you is a new story under your component that lists all the props and a main entry in the Docs tab that shows the correct and complete props table. Also, stories that use the Template: ComponentStory<T> pattern will have the full props list in their controls tab.

It's worth noting that wrapping the new component in ComponentStory does not work:

// eslint-disable-next-line react/jsx-props-no-spreading
const ButtonProps: React.FC<ButtonPropsType> = (props) => <Button {...props} />;
// eslint-disable-next-line react/jsx-props-no-spreading
export const ButtonPropsStory: ComponentStory<typeof ButtonProps> = (args) => <ButtonProps {...args} />;

// …the same default export as above

Hopefully this workaround helps you, and hopefully it also provides some info to anyone else about how to track down the bug that requires this workaround in the first place!

@stale
Copy link

stale bot commented Jan 9, 2022

Hi everyone! Seems like there hasn't been much going on in this issue lately. If there are still questions, comments, or bugs, please feel free to continue the discussion. Unfortunately, we don't have time to get to every issue. We are always open to contributions so please send us a pull request if you would like to help. Inactive issues will be closed after 30 days. Thanks!

@stale stale bot added the inactive label Jan 9, 2022
@shadigaafar
Copy link

I'm having the same problem, whenever i use forwardRef docs table in not generated.

@stale stale bot removed the inactive label Jun 20, 2022
@shadigaafar
Copy link

const ButtonProps: React.FC<ButtonPropsType> = (props) => <Button {...props} />

@tupton , your solution did not work for me.

@spqsh
Copy link

spqsh commented Jul 27, 2022

Made a little investigation and here's what I found

TL;DR

While declaring button component, set it's type this way:

export const FancyButton = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
  ...
}) as React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<HTMLButtonElement>>

Explanation

Based on React types forwardRef function looks this way:

function forwardRef<T, P = {}>(
  render: ForwardRefRenderFunction<T, P>
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;

Basically it means that component returned by forwardRef should accept props of generic type P, ref attribute of generic type T and few other props (for example key)
The most interesting part for us is PropsWithoutRef<P>. Here's it's realization:

type PropsWithoutRef<P> =
    'ref' extends keyof P
        ? Pick<P, Exclude<keyof P, 'ref'>>
        : P;

What TS does here is:

  1. Checks is ref string a valid property of P type ('ref' extends keyof P)
  2. If yes - returns type that contains all fields of P, except ref (Pick<P, Exclude<keyof P, 'ref'>>)
  3. If no - returns P type without changes

As long as ref is a string, it's valid value for index type {[key: string]: unknown}, therefore in our case PropsWithoutRef<P> is the same with Pick<P, Exclude<keyof P, 'ref'>>. Furthermore, as long as index type doesn't have explicit declaration of ref field, Pick<P, Exclude<keyof P, 'ref'>> is the same with Pick<P, keyof P>

And here's a punchline: keyof operator handles types/interfaces with index signature differently that it handles types/interfaces without index signature. Based on TS docs:

The keyof operator takes an object type and produces a string or numeric literal union of its keys.
If the type has a string or number index signature, keyof will return those types instead

In our case it means that for interface Props { x: string, y: string } expression Pick<Props, keyof Props> will return Props interface without changes, while for interface Props { x: string, y: string, [key: string]: unknown } the same expression will return {[key: string]: unknown}

So suggested workaround from the beginning of this answer fixes this issue. Iit simply copies return type from source code and replaces this:
ForwardRefExoticComponent<Pick<ButtonProps, keyof ButtonProps> & RefAttributes<HTMLButtonElement>>
...with this:
ForwardRefExoticComponent<ButtonProps & RefAttributes<HTMLButtonElement>>

But it anyway looks like dirty hack, going to create an issue in @types/react repo (if there's no such)

@umkara
Copy link

umkara commented May 15, 2023

// When using React.forwardRef ,
// the argTypes table for components may not be generated correctly.
// To resolve this issue, you can use the ForwardRefExoticComponent type
// along with PropsWithoutRef and RefAttributes from the 'react' package.
// Here's an example using a Button component:

import React, { forwardRef, ForwardRefExoticComponent, PropsWithoutRef, RefAttributes } from 'react';

interface ButtonProps {
  onClick: () => void;
}

const Button: ForwardRefExoticComponent<PropsWithoutRef<ButtonProps> & RefAttributes<HTMLButtonElement>> = forwardRef((props, ref) => {
  const { onClick, children } = props;

  return (
<button ref={ref} onClick={onClick}>
      {children}
    </button>

  );
});

export default Button;


// By combining PropsWithoutRef, RefAttributes, forwardRef, and ForwardRefExoticComponent,
// you can create a functional component in TypeScript that supports both custom props
// and the ability to forward a ref to an underlying element.
// This allows the argTypes table to be generated correctly for components
// using React.forwardRef.

`

@mbrowne
Copy link

mbrowne commented Dec 20, 2023

In case anyone is having trouble getting @tupon's workaround to work (or something similar to it), I found that Storybook will ignore any typings defined in the same file as the story unless the value used for component is exported. So the export keyword here is essential:

export const ButtonProps: React.FC<ButtonPropsType> = (props) => <Button {...props} />;

@amirtbi
Copy link

amirtbi commented Oct 16, 2024

@umkara .,
I tried your answer for the following component, but typescript displayes errors for destructured props



interface ButtonProps extends HTMLAttributes<HTMLButtonElement> {
	[key: string]: any;
	tagName?: "button" | "span" | "a";
	children?: JSX.Element | JSX.Element[] | string;
	href?: string;
	target?: HTMLAttributeAnchorTarget;
	color?: "basic" | "primary" | "red" | "green" | "orange";
	variant?: "outline" | "text";
	size?: "xs" | "sm" | "md" | "lg" | "xl";
	leftIcon?: string;
	rightIcon?: string;
	centerIcon?: string;
	type?: "button" | "submit" | "reset";
	disabled?: boolean;
}

export const Button:ForwardRefExoticComponent<ButtonProps & RefAttributes<HTMLButtonElement>> = forwardRef((props, ref) => {
	const {
		rightIcon,
		leftIcon,
		type,
		children,
		tagName,
		centerIcon,
		variant,
		color,
		size = "md",
		disabled,
		...otherProps
	} = props;
	const Component: ElementType = (tagName || (type ? "button" : "span")) as ElementType;
	const getCenterIconSpaces = () => {
		if (size === "xs") {
			return "px-2xs py-2xs";
		} else if (size === "sm") {
			return "px-xs py-xs";
		} else if (size === "md") {
			return "px-sm py-sm";
		} else if (size === "lg") {
			return "px-md py-sm";
		} else if (size === "xl") {
			return "px-xl py-xl";
		} else {
			return "px-sm py-sm";
		}
	};
	const componentClasses = (): string =>
		getClassNames(
			classNames.btn,
			variant && color && classNames[`btn-${variant}-${color}`],
			variant && !color && classNames[`btn-${variant}-primary`],
			!variant && color && classNames[`btn-${color}`],
			size && classNames[`btn-${size}`],
			disabled && classNames["btn-disabled"],
			centerIcon && getCenterIconSpaces(),

			otherProps.className && otherProps.className
		);

	return (
		<Component {...otherProps} ref={ref} className={componentClasses()} type={type}>
			{rightIcon && <Icon iconName={rightIcon} className={`ml-xs ${classNames["btn-icon"]}`} />}
			{centerIcon ? (
				<Icon iconName={centerIcon} className={classNames["btn-icon"]} />
			) : (
				<React.Fragment>{children}</React.Fragment>
			)}
			{leftIcon && <Icon iconName={leftIcon} className={`mr-xs ${classNames["btn-icon"]}`} />}
		</Component>
	);
}) 

@umkara
Copy link

umkara commented Oct 17, 2024

please try this

interface ButtonProps extends HTMLAttributes<HTMLButtonElement> {
	[key: string]: any;
	tagName?: "button" | "span" | "a";
	children?: JSX.Element | JSX.Element[] | string;
	href?: string;
	target?: HTMLAttributeAnchorTarget;
	color?: "basic" | "primary" | "red" | "green" | "orange";
	variant?: "outline" | "text";
	size?: "xs" | "sm" | "md" | "lg" | "xl";
	leftIcon?: string;
	rightIcon?: string;
	centerIcon?: string;
	type?: "button" | "submit" | "reset";
	disabled?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
	const {
		rightIcon,
		leftIcon,
		type = "button", // Default value
		children,
		tagName,
		centerIcon,
		variant,
		color,
		size = "md", // Default value for size
		disabled,
		...otherProps
	} = props;

	const Component: ElementType = tagName || (type ? "button" : "span");

	// Helper function to get padding classes for center icon
	const getCenterIconSpaces = () => {
		switch (size) {
			case "xs":
				return "px-2xs py-2xs";
			case "sm":
				return "px-xs py-xs";
			case "md":
				return "px-sm py-sm";
			case "lg":
				return "px-md py-sm";
			case "xl":
				return "px-xl py-xl";
			default:
				return "px-sm py-sm";
		}
	};

	// Function to compute component classes
	const componentClasses = (): string =>
		getClassNames(
			classNames.btn,
			variant && color && classNames[`btn-${variant}-${color}`],
			variant && !color && classNames[`btn-${variant}-primary`],
			!variant && color && classNames[`btn-${color}`],
			size && classNames[`btn-${size}`],
			disabled && classNames["btn-disabled"],
			centerIcon && getCenterIconSpaces(),
			otherProps.className // Include other custom classes passed in
		);

	return (
		<Component
			{...(otherProps as any)} // Cast to 'any' to avoid TypeScript errors for props not related to the button
			ref={ref}
			className={componentClasses()}
			type={type}
			disabled={disabled} // Ensure button handles 'disabled'
		>
			{leftIcon && <Icon iconName={leftIcon} className={`mr-xs ${classNames["btn-icon"]}`} />}
			{centerIcon ? (
				<Icon iconName={centerIcon} className={classNames["btn-icon"]} />
			) : (
				children
			)}
			{rightIcon && <Icon iconName={rightIcon} className={`ml-xs ${classNames["btn-icon"]}`} />}
		</Component>
	);
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

10 participants