diff --git a/packages/kbn-docs-utils/src/build_api_declarations/build_api_declaration.test.ts b/packages/kbn-docs-utils/src/build_api_declarations/build_api_declaration.test.ts index 949c1c828482d..0f246cf403f50 100644 --- a/packages/kbn-docs-utils/src/build_api_declarations/build_api_declaration.test.ts +++ b/packages/kbn-docs-utils/src/build_api_declarations/build_api_declaration.test.ts @@ -19,7 +19,7 @@ import { getDeclarationNodesForPluginScope } from '../get_declaration_nodes_for_ import { buildApiDeclarationTopNode } from './build_api_declaration'; import { isNamedNode } from '../tsmorph_utils'; import { getTypeKind } from './get_type_kind'; -import { getSignature } from './get_signature'; +import { getSignature, normalizeVerboseSignatures } from './get_signature'; import { buildBasicApiDeclaration } from './build_basic_api_declaration'; import { buildVariableDec } from './build_variable_dec'; import { buildCallSignatureDec } from './build_call_signature_dec'; @@ -144,8 +144,8 @@ it('Function inside interface has a label', () => { expect(fn?.type).toBe(TypeKind.FunctionKind); }); -// FAILING: https://github.com/elastic/kibana/issues/120125 -it.skip('Test ReactElement signature', () => { +// https://github.com/elastic/kibana/issues/120125 +it('Test ReactElement signature', () => { const node = nodes.find((n) => getNodeName(n) === 'AReactElementFn'); expect(node).toBeDefined(); const def = buildApiDeclarationTopNode(node!, { @@ -662,6 +662,63 @@ describe('getSignature edge cases', () => { }); }); +describe('normalizeVerboseSignatures', () => { + it('collapses ReactNode expansion back to React.ReactNode', () => { + const input = + 'string | number | boolean | React.ReactElement> | Iterable | React.ReactPortal | null | undefined'; + expect(normalizeVerboseSignatures(input)).toBe('React.ReactNode'); + }); + + it('collapses ReactNode expansion when embedded in a larger signature', () => { + const input = + '(children: string | number | boolean | React.ReactElement> | Iterable | React.ReactPortal | null | undefined) => void'; + expect(normalizeVerboseSignatures(input)).toBe('(children: React.ReactNode) => void'); + }); + + it('strips the second generic from ReactElement', () => { + const input = 'React.ReactElement>'; + expect(normalizeVerboseSignatures(input)).toBe('React.ReactElement'); + }); + + it('strips the second generic from ReactElement without the React. prefix', () => { + const input = 'ReactElement>'; + expect(normalizeVerboseSignatures(input)).toBe('React.ReactElement'); + }); + + it('collapses ComponentClass | FunctionComponent to ComponentType', () => { + const input = 'React.ComponentClass<{}, any> | React.FunctionComponent<{}>'; + expect(normalizeVerboseSignatures(input)).toBe('React.ComponentType'); + }); + + it('strips empty props default from ComponentType', () => { + const input = 'React.ComponentType<{}>'; + expect(normalizeVerboseSignatures(input)).toBe('React.ComponentType'); + }); + + it('strips ComponentType<{}> when embedded in a union', () => { + const input = 'React.ComponentType<{}> | undefined'; + expect(normalizeVerboseSignatures(input)).toBe('React.ComponentType | undefined'); + }); + + it('preserves ComponentType with non-empty props', () => { + const input = 'React.ComponentType'; + expect(normalizeVerboseSignatures(input)).toBe('React.ComponentType'); + }); + + it('applies multiple rules in a single signature', () => { + const input = + '{ node: string | number | boolean | React.ReactElement> | Iterable | React.ReactPortal | null | undefined; component: React.ComponentType<{}> }'; + expect(normalizeVerboseSignatures(input)).toBe( + '{ node: React.ReactNode; component: React.ComponentType }' + ); + }); + + it('returns the input unchanged when no patterns match', () => { + const input = '(a: string, b: number) => boolean'; + expect(normalizeVerboseSignatures(input)).toBe(input); + }); +}); + describe('getReferences edge cases', () => { it('returns empty array for node without external references', () => { const project = new Project({ diff --git a/packages/kbn-docs-utils/src/build_api_declarations/get_signature.ts b/packages/kbn-docs-utils/src/build_api_declarations/get_signature.ts index d5227d7cc1c2b..e59187c530564 100644 --- a/packages/kbn-docs-utils/src/build_api_declarations/get_signature.ts +++ b/packages/kbn-docs-utils/src/build_api_declarations/get_signature.ts @@ -71,6 +71,8 @@ export function getSignature( ); } + signature = normalizeVerboseSignatures(signature); + // Don't return the signature if it's the same as the type (string, string) if (getTypeKind(node).toString() === signature) return undefined; @@ -86,15 +88,68 @@ export function getSignature( return undefined; } + // This post-reference-extraction hack catches a *different* verbose `ReactElement` expansion + // than {@link signatureNormalizations}. After `extractImportReferences` splits the signature + // into reference link segments, the second generic appears as a separate string chunk using + // `React.Component` (not `React.JSXElementConstructor`). Both mechanisms are needed until + // this legacy hack can be replaced by a pre-extraction normalization rule. return referenceLinks.map((link) => { - // This is such a terrible hack, but the docs look really terrible with it, and I'm not sure of a better way to solve it. - // See for context. This is what the second default generic type of `ReactElement` expands to. Blech! if ( link === ', string | ((props: any) => React.ReactElement React.Component)> | null) | (new (props: any) => React.Component)>' - // ', string | ((props: any) => React.ReactElement React.Component'; } else return link; }); } + +/** + * Signature normalization rules. Each entry maps a verbose expanded generic + * default back to the concise form that developers actually write. + * + * New entries can be added here as more verbose patterns are discovered. + */ +const signatureNormalizations: Array<{ pattern: RegExp; replacement: string }> = [ + // Order matters: `ReactNode` must be collapsed before `ReactElement` because the + // `ReactNode` expansion contains a full `ReactElement` that would otherwise + // be shortened first, preventing the `ReactNode` pattern from matching. + + // `React.ReactNode` expands to a long union of primitive types, `ReactElement`, `Iterable`, + // `ReactPortal`, `null`, and `undefined`. Collapse it back to the alias. + // NOTE: This pattern is coupled to `@types/react@18`. If Kibana upgrades to React 19 types + // (which adds `bigint` and changes the union shape), this regex must be updated to match. + { + pattern: + /string \| number \| boolean \| React\.ReactElement> \| Iterable \| React\.ReactPortal \| null \| undefined/g, + replacement: 'React.ReactNode', + }, + // `ReactElement` has a second generic default (`string | React.JSXElementConstructor`) + // that expands into verbose output; strip it while keeping the first generic. + { + pattern: + /React(?:\.ReactElement|Element)<([^,>]+),\s*string \|\s*React\.JSXElementConstructor>/g, + replacement: 'React.ReactElement<$1>', + }, + // `React.ComponentClass<{}, any> | React.FunctionComponent<{}>` is the expansion of + // `React.ComponentType<{}>`. Collapse it back. + { + pattern: /React\.ComponentClass<\{\}, any> \| React\.FunctionComponent<\{\}>/g, + replacement: 'React.ComponentType', + }, + // `React.ComponentType<{}>` uses an empty props default; strip it for readability. + { + pattern: /React\.ComponentType<\{\}>/g, + replacement: 'React.ComponentType', + }, +]; + +/** + * Applies all {@link signatureNormalizations} to collapse verbose expanded + * generic defaults back to their concise aliases. + */ +export function normalizeVerboseSignatures(signature: string): string { + return signatureNormalizations.reduce( + (sig, { pattern, replacement }) => sig.replace(pattern, replacement), + signature + ); +} diff --git a/packages/kbn-docs-utils/src/integration_tests/api_doc_suite.test.ts b/packages/kbn-docs-utils/src/integration_tests/api_doc_suite.test.ts index 61f9408a27c4e..002ee6e18e7df 100644 --- a/packages/kbn-docs-utils/src/integration_tests/api_doc_suite.test.ts +++ b/packages/kbn-docs-utils/src/integration_tests/api_doc_suite.test.ts @@ -360,7 +360,7 @@ describe('Types', () => { "section": "def-public.MyProps", "text": "MyProps", }, - ", string | React.JSXElementConstructor>", + ">", ] `); }); diff --git a/packages/kbn-docs-utils/src/integration_tests/snapshots/plugin_a.devdocs.json b/packages/kbn-docs-utils/src/integration_tests/snapshots/plugin_a.devdocs.json index 666d308b99ef2..47e34541ea6ec 100644 --- a/packages/kbn-docs-utils/src/integration_tests/snapshots/plugin_a.devdocs.json +++ b/packages/kbn-docs-utils/src/integration_tests/snapshots/plugin_a.devdocs.json @@ -82,7 +82,7 @@ "label": "component", "description": [], "signature": [ - "React.ComponentType<{}> | undefined" + "React.ComponentType | undefined" ], "path": "packages/kbn-docs-utils/src/integration_tests/__fixtures__/src/plugin_a/public/classes.ts", "lineNumber": 50, @@ -1263,7 +1263,7 @@ "label": "component", "description": [], "signature": [ - "React.ComponentClass<{}, any> | React.FunctionComponent<{}>" + "React.ComponentType" ], "path": "packages/kbn-docs-utils/src/integration_tests/__fixtures__/src/plugin_a/public/classes.ts", "lineNumber": 117, @@ -1512,7 +1512,7 @@ "section": "def-public.MyProps", "text": "MyProps" }, - ", string | React.JSXElementConstructor>" + ">" ], "path": "packages/kbn-docs-utils/src/integration_tests/__fixtures__/src/plugin_a/public/types.ts", "lineNumber": 65,