From 0389edb76c7a2eecf82de2dbc51b99add2462c03 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Tue, 30 Jun 2026 09:22:34 -0700 Subject: [PATCH] Reattach default-export doc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: **Context** Strict TypeScript API readiness: High quality inline docs should reach users via TypeScript in their IDEs. **This diff** Prior iterations of our Flow → TS translation stack dropped doc comments for default-exported values, and/or identifiers which change shape after type transformation (e.g. Flow `component` syntax), meaning many root APIs (`View`, `ScrollView`, `Pressable`, and others) showed no documentation on hover. This diff extends the existing `reattachDocComments` transform to handle doc comment repositioning (suitable for the TS lang server) from a greater set of source positions: - the exported declaration - a `.displayName` assignment - a HOC-wrapped inner component - a renamed wrapper's public-named component - a `declare const` / `declare export default typeof X` stub **Impact** (With the source code JSDoc improvements earlier in this stack.) | Before (legacy types) | After (Strict API) | | -- | | {F1991869487} | {F1991869472} | | ⚠️ No inline docs for many symbols | ✅ New, detailed inline docs reach the TS server 🎉 | Changelog: [General][Fixed] - Preserve doc comments on root API symbols in the generated TypeScript types Differential Revision: D109316361 --- .../__tests__/reattachDocComments-test.js | 164 ++++++++++++ .../transforms/flow/reattachDocComments.js | 236 +++++++++++++++--- 2 files changed, 369 insertions(+), 31 deletions(-) diff --git a/scripts/js-api/build-types/transforms/flow/__tests__/reattachDocComments-test.js b/scripts/js-api/build-types/transforms/flow/__tests__/reattachDocComments-test.js index dc289f67a317..05876628bbdb 100644 --- a/scripts/js-api/build-types/transforms/flow/__tests__/reattachDocComments-test.js +++ b/scripts/js-api/build-types/transforms/flow/__tests__/reattachDocComments-test.js @@ -19,6 +19,17 @@ async function translate(code: string): Promise { return print(result.ast, result.mutatedCode, prettierOptions); } +/** + * Assert that `marker` appears exactly once (moved, not duplicated) and is the + * leading comment of the `export default` declaration. + */ +function expectDocOnDefaultExport(result: string, marker: string): void { + expect(result.split(marker).length - 1).toBe(1); + expect(result).toMatch( + new RegExp(`/\\*\\*[\\s\\S]*?${marker}[\\s\\S]*?\\*/\\s*export default`), + ); +} + describe('reattachDocComments', () => { test('should move component doc block', async () => { const code = ` @@ -53,6 +64,7 @@ describe('reattachDocComments', () => { " `); }); + test('should move variable doc block', async () => { const code = ` const bar = 'bar'; @@ -83,4 +95,156 @@ describe('reattachDocComments', () => { " `); }); + + test('should move doc block from a `component` declaration', async () => { + const code = ` + import * as React from 'react'; + + /** + * Foo documentation + */ + component Foo(...props: FooProps) { + return null; + } + + Foo.displayName = 'Foo'; + + export default Foo; + `; + const result = await translate(code); + expectDocOnDefaultExport(result, 'Foo documentation'); + }); + + test('should move doc block from a memo-wrapped component via its display name', async () => { + const code = ` + import * as React from 'react'; + + /** + * Foo documentation + */ + function Foo(props: FooProps) { + return null; + } + + const MemoedFoo = memo(Foo); + MemoedFoo.displayName = 'Foo'; + + export default MemoedFoo; + `; + const result = await translate(code); + expectDocOnDefaultExport(result, 'Foo documentation'); + }); + + test('should move doc block from the public (display) named declaration', async () => { + const code = ` + import * as React from 'react'; + + /** + * Foo documentation + */ + class Foo extends React.Component {} + + const FooWrapper = (props: FooProps) => null; + FooWrapper.displayName = 'Foo'; + + export default FooWrapper; + `; + const result = await translate(code); + expectDocOnDefaultExport(result, 'Foo documentation'); + }); + + test('should reattach through an `as` cast in the default export', async () => { + const code = ` + import * as React from 'react'; + + /** + * Foo documentation + */ + const Foo = (props: FooProps) => null; + + export default Foo as FooType; + `; + const result = await translate(code); + expectDocOnDefaultExport(result, 'Foo documentation'); + }); + + test('should leave the doc block on a directly class-exported declaration', async () => { + const code = ` + import * as React from 'react'; + + /** + * Foo documentation + */ + class Foo extends React.Component {} + + export default Foo; + `; + const result = await translate(code); + // TypeScript resolves the doc on the class declaration; it must stay there + // and must not be moved onto the `export default` statement. + expect(result.split('Foo documentation').length - 1).toBe(1); + expect(result).toMatch(/\*\/\s*class Foo/); + expect(result).not.toMatch(/\*\/\s*export default/); + }); + + test('should move doc block from a `declare const` (.js.flow stub)', async () => { + const code = ` + 'use strict'; + + /** + * Foo documentation + */ + declare const Foo: {bar(): void}; + + export default Foo; + `; + const result = await translate(code); + expectDocOnDefaultExport(result, 'Foo documentation'); + }); + + test('should reattach for `declare export default typeof Foo`', async () => { + const code = ` + 'use strict'; + + /** + * Foo documentation + */ + declare const Foo: {bar(): void}; + + declare export default typeof Foo; + `; + const result = await translate(code); + expect(result.split('Foo documentation').length - 1).toBe(1); + expect(result).toMatch( + /\/\*\*[\s\S]*?Foo documentation[\s\S]*?\*\/\s*declare export default/, + ); + }); + + test('should not duplicate a doc block already on the default export', async () => { + const code = ` + const Foo = (props: FooProps) => null; + + /** + * Foo documentation + */ + export default Foo; + `; + const result = await translate(code); + expectDocOnDefaultExport(result, 'Foo documentation'); + }); + + test('should ignore build directive comments', async () => { + const code = ` + import * as React from 'react'; + + /** @build-types emit-as-interface */ + const Foo = (props: FooProps) => null; + + export default Foo; + `; + const result = await translate(code); + // The directive must not be moved onto (or left as a leading comment of) + // the default export. + expect(result).not.toMatch(/\*\/\s*export default/); + }); }); diff --git a/scripts/js-api/build-types/transforms/flow/reattachDocComments.js b/scripts/js-api/build-types/transforms/flow/reattachDocComments.js index d68badbc62d6..10cb040194ce 100644 --- a/scripts/js-api/build-types/transforms/flow/reattachDocComments.js +++ b/scripts/js-api/build-types/transforms/flow/reattachDocComments.js @@ -8,54 +8,228 @@ * @format */ -import type {ExportDefaultDeclaration, Program} from 'hermes-estree/dist'; +import type { + Comment, + ESNode, + ModuleDeclaration, + Program, + Statement, +} from 'hermes-estree/dist'; import type {TransformVisitor} from 'hermes-transform'; import type {ParseResult} from 'hermes-transform/dist/transform/parse'; import type {TransformASTResult} from 'hermes-transform/dist/transform/transformAST'; const {transformAST} = require('hermes-transform/dist/transform/transformAST'); +type BodyNode = Statement | ModuleDeclaration; + +/** + * Move the doc comment for a module's default export onto its + * `export default` declaration. + * + * `flow-api-translator` strips the runtime implementation of a module, which + * either drops or strands the doc comment for the default-exported value. This + * matters because the doc comment must end up on the symbol that TypeScript + * resolves a re-export to — for default exports that is the synthetic + * `export default` const, not the inner implementation symbol it aliases via + * `typeof`. + */ const visitors: TransformVisitor = context => ({ - Program(node): void { - const getExportDefaultDeclaration = ( - programNode: Program, - ): [ExportDefaultDeclaration, string] | null => { - for (const bodyNode of programNode.body) { - if (bodyNode.type === 'ExportDefaultDeclaration') { - if (bodyNode.declaration.type === 'Identifier') { - return [bodyNode, bodyNode.declaration.name]; - } - } - } + Program(node: Program): void { + const exportDefault = node.body.find( + bodyNode => + bodyNode.type === 'ExportDefaultDeclaration' || + (bodyNode.type === 'DeclareExportDeclaration' && + bodyNode.default === true), + ); + if (exportDefault == null) { + return; + } - return null; - }; + // Leave already-documented (and inline) default exports untouched. + if (context.getLeadingComments(exportDefault).some(isDocComment)) { + return; + } - const exportDefaultDeclResult = getExportDefaultDeclaration(node); - if (exportDefaultDeclResult == null) { + // Both forms carry the exported value via `declaration`. + let exportedDeclaration: ?ESNode = null; + if (exportDefault.type === 'ExportDefaultDeclaration') { + exportedDeclaration = exportDefault.declaration; + } else if ( + exportDefault.type === 'DeclareExportDeclaration' && + exportDefault.default === true + ) { + exportedDeclaration = exportDefault.declaration; + } + if (exportedDeclaration == null) { return; } - const [exportDefaultDecl, exportDefaultDeclName] = exportDefaultDeclResult; - for (const bodyNode of node.body) { - if (bodyNode.type === 'VariableDeclaration') { - for (const decl of bodyNode.declarations) { - if ( - decl.id.type === 'Identifier' && - decl.id.name === exportDefaultDeclName - ) { - const comments = context.getComments(bodyNode); - if (comments != null) { - context.addLeadingComments(exportDefaultDecl, comments); - context.removeComments(comments); - } - } - } + const exportedName = getExportedIdentifierName(exportedDeclaration); + if (exportedName == null) { + return; + } + + const exportedDecl = findDeclarationByName(node.body, exportedName); + + // A directly-exported class keeps its declaration, where TypeScript already + // resolves the doc comment; moving it would hide it. + if (exportedDecl?.kind === 'ClassDeclaration') { + return; + } + + for (const source of findDocCommentSources( + node.body, + exportedName, + exportedDecl, + )) { + const docComments = context + .getLeadingComments(source) + .filter(isDocComment); + if (docComments.length > 0) { + context.addLeadingComments(exportDefault, docComments); + context.removeComments(docComments); + return; } } }, }); +/** + * A JSDoc-style block comment (`/** ... *‍/`), excluding the license/pragma + * docblock and build directives such as `@build-types emit-as-interface`. + */ +function isDocComment(comment: Comment): boolean { + return ( + comment.type === 'Block' && + comment.value.startsWith('*') && + !/@(flow|noflow|format|build-types)\b/.test(comment.value) && + !comment.value.includes('Copyright') + ); +} + +/** + * Name of the exported identifier, unwrapping `as` casts and the `typeof X` + * form. Returns `null` for inline/anonymous declarations. + */ +function getExportedIdentifierName(declaration: ESNode): ?string { + let node: ESNode = declaration; + while (node.type === 'AsExpression' || node.type === 'TypeCastExpression') { + node = node.expression; + } + if (node.type === 'TypeofTypeAnnotation') { + return node.argument.type === 'Identifier' ? node.argument.name : null; + } + return node.type === 'Identifier' ? node.name : null; +} + +/** + * Statements whose leading comment may document the default export, in priority + * order. + */ +function findDocCommentSources( + body: ReadonlyArray, + exportedName: string, + exportedDecl: ?DeclarationMatch, +): Array { + const sources: Array = []; + + if (exportedDecl != null) { + sources.push(exportedDecl.statement); + } + + // Renamed wrappers (e.g. `memo`-wrapped, or `ScrollViewWrapper` shown as + // `ScrollView`) carry the doc on the declaration matching the display name. + const publicName = findDisplayNameAssignment(body, exportedName)?.publicName; + if (publicName != null && publicName !== exportedName) { + const publicDecl = findDeclarationByName(body, publicName); + if (publicDecl != null) { + sources.push(publicDecl.statement); + } + } + + return sources; +} + +type DeclarationMatch = { + statement: BodyNode, + kind: string, +}; + +function findDeclarationByName( + body: ReadonlyArray, + name: string, +): ?DeclarationMatch { + for (const statement of body) { + const declaration = + statement.type === 'ExportNamedDeclaration' && + statement.declaration != null + ? statement.declaration + : statement; + + if ( + (declaration.type === 'ClassDeclaration' || + declaration.type === 'FunctionDeclaration' || + declaration.type === 'ComponentDeclaration') && + declaration.id != null && + declaration.id.name === name + ) { + return {statement, kind: declaration.type}; + } + + // `declare const X` in `.js.flow` declaration files + if ( + declaration.type === 'DeclareVariable' && + declaration.id.name === name + ) { + return {statement, kind: declaration.type}; + } + + if (declaration.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + if ( + declarator.id.type === 'Identifier' && + declarator.id.name === name + ) { + return {statement, kind: declaration.type}; + } + } + } + } + + return null; +} + +type DisplayNameMatch = {statement: BodyNode, publicName: ?string}; + +function findDisplayNameAssignment( + body: ReadonlyArray, + name: string, +): ?DisplayNameMatch { + for (const statement of body) { + if ( + statement.type === 'ExpressionStatement' && + statement.expression.type === 'AssignmentExpression' + ) { + const {left, right} = statement.expression; + if ( + left.type === 'MemberExpression' && + left.object.type === 'Identifier' && + left.object.name === name && + left.property.type === 'Identifier' && + left.property.name === 'displayName' + ) { + const publicName = + right.type === 'Literal' && typeof right.value === 'string' + ? right.value + : null; + return {statement, publicName}; + } + } + } + return null; +} + async function reattachDocComments( source: ParseResult, ): Promise {