Repository: styleguidist/react-docgen-typescript
Pull request #398 (2021-11-15 03:42:01)

feat: Provide raw expression as part of parser output
This is re: https://github.com/hipstersmoothie/react-docgen-typescript-plugin/issues/51 and https://github.com/storybookjs/storybook/issues/15401 cc @hipstersmoothie @leepowelldev

**tl;dr** This PR would allow consumers who parse components with `react-docgen-typescript` to access the expression for that component.

## Use case

In Storybook, a component's prop table will break if you set the component's `displayName` to a value that differs from its identifier. For example, let's say that you define a component like this:

```ts
export const Button = (props: ButtonComponentProps) => (
  <button>{props.text}</button>
);

Button.displayName = "MyButtonDisplayName";
```

Then [`react-docgen-typescript-plugin`](https://github.com/hipstersmoothie/react-docgen-typescript-plugin) will append code to that module like this:

```ts
try {
    // @ts-ignore
    MyButtonDisplayName.displayName = "MyButtonDisplayName";
    // @ts-ignore
    MyButtonDisplayName.__docgenInfo = { "description": "", "displayName": "MyButtonDisplayName", "props": { "text": { "defaultValue": null, "description": "", "name": "text", "required": true, "type": { "name": "string" } } } };
}
catch (__react_docgen_typescript_loader_error) { }
```

That code will be a no-op, because `MyButtonDisplayName` is an invalid reference.

Why does `react-docgen-typescript-plugin` behave this way? Because the `react-docgen-typescript` parser doesn't return any way of obtaining the component's identifier other than `displayName`.

If the parser returned the component `expression`, then consumers like `react-docgen-typescript-plugin` could use `expression.getName()` instead of `displayName` to correctly handle cases where `displayName` is a different value.

---

Note that I opted to return the `expression` object here, rather than just the result of `expression.getName()`, because it might help to address another case: unnamed default exports. `react-docgen-typescript-plugin` fails for component declarations of the form `export default function() { ... }`, since there's no identifier to reference the component in that case. But using the expression, it might be able to inject one—that is, it could convert the component declaration to one of the form `export default function SomeGeneratedName() { ... }`, making it referencable.


Patch:
diff --git a/src/parser.ts b/src/parser.ts
index 8edf153..c7a1373 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -14,6 +14,7 @@ export interface StringIndexedObject<T> {
 }
 
 export interface ComponentDoc {
+  expression?: ts.Symbol;
   displayName: string;
   filePath: string;
   description: string;
@@ -88,6 +89,7 @@ export interface ParserOptions {
   skipChildrenPropWithoutDoc?: boolean;
   savePropValueAsString?: boolean;
   shouldIncludePropTagMap?: boolean;
+  shouldIncludeExpression?: boolean;
   customComponentTypes?: string[];
 }
 
@@ -219,6 +221,7 @@ export class Parser {
   private readonly shouldExtractValuesFromUnion: boolean;
   private readonly savePropValueAsString: boolean;
   private readonly shouldIncludePropTagMap: boolean;
+  private readonly shouldIncludeExpression: boolean;
 
   constructor(program: ts.Program, opts: ParserOptions) {
     const {
@@ -226,7 +229,8 @@ export class Parser {
       shouldExtractLiteralValuesFromEnum,
       shouldRemoveUndefinedFromOptional,
       shouldExtractValuesFromUnion,
-      shouldIncludePropTagMap
+      shouldIncludePropTagMap,
+      shouldIncludeExpression
     } = opts;
     this.checker = program.getTypeChecker();
     this.propFilter = buildFilter(opts);
@@ -239,6 +243,7 @@ export class Parser {
     this.shouldExtractValuesFromUnion = Boolean(shouldExtractValuesFromUnion);
     this.savePropValueAsString = Boolean(savePropValueAsString);
     this.shouldIncludePropTagMap = Boolean(shouldIncludePropTagMap);
+    this.shouldIncludeExpression = Boolean(shouldIncludeExpression);
   }
 
   private getComponentFromExpression(exp: ts.Symbol) {
@@ -350,6 +355,7 @@ export class Parser {
       computeComponentName(nameSource, source, customComponentTypes);
     const methods = this.getMethodsInfo(type);
 
+    let result: ComponentDoc | null = null;
     if (propsType) {
       if (!commentSource.valueDeclaration) {
         return null;
@@ -367,7 +373,7 @@ export class Parser {
           delete props[propName];
         }
       }
-      return {
+      result = {
         tags,
         filePath,
         description,
@@ -376,7 +382,7 @@ export class Parser {
         props
       };
     } else if (description && displayName) {
-      return {
+      result = {
         tags,
         filePath,
         description,
@@ -386,7 +392,11 @@ export class Parser {
       };
     }
 
-    return null;
+    if (result !== null && this.shouldIncludeExpression) {
+      result.expression = rootExp;
+    }
+
+    return result;
   }
 
   public extractPropsFromTypeIfStatelessComponent(


Test patch:
diff --git a/src/__tests__/parser.ts b/src/__tests__/parser.ts
index f114979..e4ec11c 100644
--- a/src/__tests__/parser.ts
+++ b/src/__tests__/parser.ts
@@ -1494,6 +1494,20 @@ describe('parser', () => {
         );
       });
     });
+
+    describe('shouldIncludeExpression', () => {
+      it('should be disabled by default', () => {
+        const [parsed] = parse(fixturePath('StatelessDisplayName'));
+        assert.equal(parsed.expression, undefined);
+      });
+
+      it('should cause the parser to return the component expression when set to true', () => {
+        const [parsed] = parse(fixturePath('StatelessDisplayName'), {
+          shouldIncludeExpression: true
+        });
+        assert.equal(parsed.expression!.name, 'Stateless');
+      });
+    });
   });
 
   describe('withCustomConfig', () => {