Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-beans-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-docgen': patch
---

Support Babel 8 dependency overrides for parsing and traversal, including TypeScript generic props, inherited interfaces, function types, and mapped types. Preserve Babel 7 compatibility.
33 changes: 29 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,33 @@ env:
jobs:
tests:
strategy:
fail-fast: false
matrix:
babel: ["7", "8"]
node: ["22.13.0", "24", "25", "26"]
os: ["ubuntu"]
exclude:
# Babel 8 requires Node.js 22.18.0 or newer on the 22.x line.
- babel: "8"
node: "22.13.0"
include:
- os: ubuntu
node: "22.18.0"
babel: "8"
- os: macos
node: "24"
babel: "7"
- os: macos
node: "24"
babel: "8"
- os: windows
node: "24"
babel: "7"
- os: windows
node: "24"
babel: "8"

name: Tests (Node.js v${{ matrix.node }}, ${{ matrix.os }})
name: Tests (Babel ${{ matrix.babel }}, Node.js v${{ matrix.node }}, ${{ matrix.os }})
runs-on: ${{ matrix.os }}-latest

steps:
Expand All @@ -43,21 +60,29 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

# Compile against the declared Babel 7 types before testing each runtime.
- name: Build packages
run: pnpm build

- name: Install Babel 8 runtime
if: "${{ matrix.babel == '8' }}"
run: pnpm --filter react-docgen add --save-exact @babel/core@8.0.1 @babel/traverse@8.0.4 @babel/types@8.0.4

- name: Unit tests with coverage
if: "${{ matrix.node == env.NODE_VERSION }}"
run: pnpm test -- --coverage
run: pnpm --filter react-docgen --filter @react-docgen/cli --parallel --no-bail test --coverage

- name: Unit tests
if: "${{ matrix.node != env.NODE_VERSION }}"
run: pnpm test
run: pnpm --filter react-docgen --filter @react-docgen/cli --parallel --no-bail test

- name: Upload coverage
if: "${{ matrix.node == env.NODE_VERSION }}"
continue-on-error: true
uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2
with:
parallel: true
flag-name: test-${{ matrix.os }}
flag-name: test-babel-${{ matrix.babel }}-${{ matrix.os }}

finish:
needs: tests
Expand Down
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ We actively welcome your pull requests.
4. Ensure the test suite passes.
5. Make sure your code lints and typechecks.

## Babel compatibility tests

CI runs all library and CLI tests separately with Babel 7 and Babel 8.
Both runs build with the declared Babel 7 dependencies first. The Babel 8
run then replaces the runtime dependencies, including those used by CLI
subprocesses. This checks runtime compatibility, not compilation with Babel 8 types.

To reproduce the Babel 8 run in a clean checkout:

```sh
pnpm install --frozen-lockfile
pnpm build
pnpm --filter react-docgen add --save-exact @babel/core@8.0.1 @babel/traverse@8.0.4 @babel/types@8.0.4
pnpm --filter react-docgen --filter @react-docgen/cli --parallel --no-bail test --coverage
```

The install command changes `packages/react-docgen/package.json` and
`pnpm-lock.yaml`. After testing, restore those two files and run
`pnpm install --frozen-lockfile` to return to Babel 7.

## Issues
We use GitHub issues to track public bugs. Please ensure your description is
clear and has sufficient instructions to be able to reproduce the issue.
Expand Down
73 changes: 73 additions & 0 deletions packages/react-docgen/src/__tests__/parserCompatibility-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { parse } from '../main.js';
import { expect, test } from 'vitest';

test('parses components', () => {
const result = parse('export function Button() { return <button />; }');

expect(result).toHaveLength(1);
});

test('parses TypeScript function props', () => {
const result = parse(
`export type MenuProps = {
onOpenChange?: (open: boolean) => void;
};

export function Menu(_props: MenuProps) {
return <div />;
}`,
{ filename: 'index.tsx' },
);

expect(result[0]?.props?.onOpenChange?.tsType).toEqual({
name: 'signature',
type: 'function',
raw: '(open: boolean) => void',
signature: {
arguments: [
{
name: 'open',
type: { name: 'boolean' },
},
],
return: { name: 'void' },
},
});
});

test('parses generic arrow functions in TypeScript files', () => {
const result = parse(
`import React from 'react';

export const mockDomain = <Entity>(
entities: Record<string, Entity> = {},
) => ({ entities });

export function Button() {
return React.createElement('button');
}`,
{ filename: 'store.ts' },
);

expect(result).toHaveLength(1);
});

test('parses mapped TypeScript props', () => {
const result = parse(
`export type StatusFiltersProps<K extends string = string> = {
statuses?: { readonly [Key in K]: number };
};

export function StatusFilters<K extends string = string>(
_props: StatusFiltersProps<K>,
) {
return <div />;
}`,
{ filename: 'index.tsx' },
);

expect(result[0]?.props?.statuses?.tsType).toMatchObject({
name: 'signature',
type: 'object',
});
});
56 changes: 40 additions & 16 deletions packages/react-docgen/src/babelParser.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,58 @@
import type { ParserOptions, TransformOptions } from '@babel/core';
import { loadPartialConfig, parseSync } from '@babel/core';
import * as babel from '@babel/core';
import type { File } from '@babel/types';
import { extname } from 'path';

const TYPESCRIPT_EXTS = new Set(['.cts', '.mts', '.ts', '.tsx']);
const { parseSync, version } = babel;
// Babel 7 exports this at runtime, but its DefinitelyTyped definitions omit it.
const loadPartialConfigSync = (
babel as typeof babel & {
loadPartialConfigSync: typeof babel.loadPartialConfig;
}
).loadPartialConfigSync;
const IS_BABEL_8 = Number.parseInt(version, 10) >= 8;

function getDefaultPlugins(
options: TransformOptions,
): NonNullable<ParserOptions['plugins']> {
return [
'jsx',
options.filename && TYPESCRIPT_EXTS.has(extname(options.filename))
? 'typescript'
: 'flow',
const extension = options.filename ? extname(options.filename) : '';
const isTypeScript = TYPESCRIPT_EXTS.has(extension);
const plugins: NonNullable<ParserOptions['plugins']> = [
...(isTypeScript && extension !== '.tsx' ? [] : ['jsx' as const]),
isTypeScript ? 'typescript' : 'flow',
'asyncDoExpressions',
'decimal',
];

if (!IS_BABEL_8) {
plugins.push('decimal');
}

plugins.push(
['decorators', { decoratorsBeforeExport: false }],
'decoratorAutoAccessors',
'destructuringPrivate',
'doExpressions',
'exportDefaultFrom',
'functionBind',
'importAssertions',
'moduleBlocks',
'partialApplication',
['pipelineOperator', { proposal: 'minimal' }],
['recordAndTuple', { syntaxType: 'bar' }],
'regexpUnicodeSets',
'throwExpressions',
];
);

if (!IS_BABEL_8) {
plugins.push('importAssertions');
}

plugins.push('moduleBlocks', 'partialApplication', [
'pipelineOperator',
{ proposal: IS_BABEL_8 ? 'fsharp' : 'minimal' },
]);

if (!IS_BABEL_8) {
plugins.push(['recordAndTuple', { syntaxType: 'bar' }]);
}

plugins.push('regexpUnicodeSets', 'throwExpressions');

return plugins;
}

function buildPluginList(
Expand All @@ -42,7 +66,7 @@ function buildPluginList(

// Let's check if babel finds a config file for this source file
// If babel does find a config file we do not apply our defaults
const partialConfig = loadPartialConfig(options);
const partialConfig = loadPartialConfigSync(options);

if (
plugins.length === 0 &&
Expand Down
4 changes: 3 additions & 1 deletion packages/react-docgen/src/handlers/codeTypeHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ function setPropDescriptor(
},
typeParams,
);
} else if (!argument.has('typeParameters')) {
} else if (!(
'typeParameters' in argument.node && argument.node.typeParameters
)) {
documentation.addComposes(id.node.name);
}
} else if (path.isObjectTypeProperty()) {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-docgen/src/handlers/displayNameHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const displayNameHandler: Handler = function (
if (
(componentDefinition.isClassDeclaration() ||
componentDefinition.isFunctionDeclaration()) &&
componentDefinition.has('id')
componentDefinition.node.id
) {
documentation.set(
'displayName',
Expand Down
7 changes: 4 additions & 3 deletions packages/react-docgen/src/importer/makeFsImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,13 +196,14 @@ export default function makeFsImporter(
}
} else if (
declaration.hasNode() &&
declaration.has('id') &&
'id' in declaration.node &&
declaration.node.id &&
(declaration.get('id') as NodePath).isIdentifier({ name })
) {
// export function/class/type/interface/enum ...

state.resultPath = declaration;
} else if (path.has('specifiers')) {
} else if (path.node.specifiers.length > 0) {
// export { ... } or export x from ... or export * as x from ...

for (const specifierPath of path.get('specifiers')) {
Expand All @@ -213,7 +214,7 @@ export default function makeFsImporter(

if (exported.isIdentifier({ name })) {
// export ... from ''
if (path.has('source')) {
if (path.node.source) {
const local = specifierPath.isExportSpecifier()
? specifierPath.node.local.name
: 'default';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import getTypeArguments from '../getTypeArguments.js';
import type {
TSTypeAliasDeclaration,
TSTypeParameterDeclaration,
Expand All @@ -20,9 +21,9 @@ describe('getTypeParameters', () => {
expect(
getTypeParameters(
path.get('typeParameters') as NodePath<TSTypeParameterDeclaration>,
path
.get('typeAnnotation')
.get('typeParameters') as NodePath<TSTypeParameterInstantiation>,
getTypeArguments(
path.get('typeAnnotation'),
) as NodePath<TSTypeParameterInstantiation>,
null,
),
).toMatchSnapshot();
Expand All @@ -35,9 +36,9 @@ describe('getTypeParameters', () => {
expect(
getTypeParameters(
path.get('typeParameters') as NodePath<TSTypeParameterDeclaration>,
path
.get('typeAnnotation')
.get('typeParameters') as NodePath<TSTypeParameterInstantiation>,
getTypeArguments(
path.get('typeAnnotation'),
) as NodePath<TSTypeParameterInstantiation>,
null,
),
).toMatchSnapshot();
Expand Down
8 changes: 6 additions & 2 deletions packages/react-docgen/src/utils/getFlowType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@ function handleGenericTypeAnnotation(
const resolvedPath =
(typeParams && typeParams[type.name]) || resolveToValue(path.get('id'));

if (typeParameters.hasNode() && resolvedPath.has('typeParameters')) {
if (
typeParameters.hasNode() &&
'typeParameters' in resolvedPath.node &&
resolvedPath.node.typeParameters
) {
typeParams = getTypeParameters(
resolvedPath.get('typeParameters') as NodePath<TypeParameterDeclaration>,
typeParameters,
Expand All @@ -195,7 +199,7 @@ function handleGenericTypeAnnotation(
);
}

if (resolvedPath && resolvedPath.has('right')) {
if (resolvedPath && 'right' in resolvedPath.node && resolvedPath.node.right) {
type = getFlowTypeWithResolvedTypes(
resolvedPath.get('right') as NodePath<FlowType>,
typeParams,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-docgen/src/utils/getPropertyName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export default function getPropertyName(
}

return null;
} else if (propertyPath.has('computed')) {
} else if ('computed' in propertyPath.node && propertyPath.node.computed) {
const key = propertyPath.get('key') as NodePath<Expression>;

// Try to resolve variables and member expressions
Expand Down
Loading