From f5da0a5f50a1f58822b14ac2acd030e6216470ec Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Fri, 14 Aug 2026 15:00:27 -0400 Subject: [PATCH 1/6] refactor(react, docs): derive component props from the API schema, drop react-docgen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The properties tables in the docs UI (the Compositions "properties" tab and the Overview properties table) were fed by react-docgen 5.3.1 via the legacy `ConsumerComponent.docs` doclets. Point `ReactMain.getDocs` at the TypeScript schema extractor instead and remove react-docgen entirely. react-docgen ran for every non-test file on every cold-cache component load — 3,224 parses in this workspace, of which 2,942 produced nothing — and it read props without type information. The schema extractor already computes this data for the API reference. Measured against real schema artifacts, prop counts match react-docgen exactly (avatar 8, component-preview 15, lane-selector 14, version-dropdown 7, tooltip 3, time-ago 2) with more precise types and default values react-docgen missed. `bit show --legacy` now reports jsdoc-only docs for React components. Co-Authored-By: Claude Opus 5 (1M context) --- .../fixtures/jsdoc/react/elevation.tsxx | 22 --- .../fixtures/jsdoc/react/react-docs.js | 76 -------- components/semantics/doc-parser/parser.ts | 12 +- .../semantics/doc-parser/react/index.ts | 3 - .../doc-parser/react/react-parser.spec.ts | 79 -------- .../doc-parser/react/react-parser.ts | 124 ------------ .../react/react-docs-from-schema.spec.ts | 176 ++++++++++++++++++ scopes/react/react/react-docs-from-schema.ts | 148 +++++++++++++++ scopes/react/react/react.graphql.ts | 2 +- scopes/react/react/react.main.runtime.ts | 47 +++-- workspace.jsonc | 1 - 11 files changed, 358 insertions(+), 332 deletions(-) delete mode 100644 components/semantics/doc-parser/fixtures/jsdoc/react/elevation.tsxx delete mode 100644 components/semantics/doc-parser/fixtures/jsdoc/react/react-docs.js delete mode 100644 components/semantics/doc-parser/react/index.ts delete mode 100644 components/semantics/doc-parser/react/react-parser.spec.ts delete mode 100644 components/semantics/doc-parser/react/react-parser.ts create mode 100644 scopes/react/react/react-docs-from-schema.spec.ts create mode 100644 scopes/react/react/react-docs-from-schema.ts diff --git a/components/semantics/doc-parser/fixtures/jsdoc/react/elevation.tsxx b/components/semantics/doc-parser/fixtures/jsdoc/react/elevation.tsxx deleted file mode 100644 index c82fd934196a..000000000000 --- a/components/semantics/doc-parser/fixtures/jsdoc/react/elevation.tsxx +++ /dev/null @@ -1,22 +0,0 @@ -// @bit-no-check - -import React from 'react'; - -export type CardProps = { - /** - * Controls the shadow cast by the card, to generate a "stacking" effects. - * For example, a modal floating over elements may have a 'high' elevation - */ - elevation: 'none' | 'low' | 'medium' | 'high'; -} & React.HTMLAttributes; - -/** - * A wrapper resembling a physical card, grouping elements and improve readability. - */ -export function Card({ className, elevation }: CardProps) { - return
; -} - -Card.defaultProps = { - elevation: 'low' -}; diff --git a/components/semantics/doc-parser/fixtures/jsdoc/react/react-docs.js b/components/semantics/doc-parser/fixtures/jsdoc/react/react-docs.js deleted file mode 100644 index bb22e6e74fab..000000000000 --- a/components/semantics/doc-parser/fixtures/jsdoc/react/react-docs.js +++ /dev/null @@ -1,76 +0,0 @@ -// @bit-no-check -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; - -/** - * @description Styled button component for the rich and famous! - * - * @example - * - ); - } -} - -Button.propTypes = { - /** - * @property {propTypes.string} text - Button text. - */ - text: PropTypes.string.isRequired, - /** - * @property {propTypes.string} buttonHoverColor - Button color to be shown on hover. - */ - buttonHoverColor: PropTypes.string, - /** - * @property {propTypes.string} buttonColor- Button default background color. - */ - buttonColor: PropTypes.string -}; - -Button.defaultProps = { - text: 'Example Button', - buttonColor: 'blue', - buttonHoverColor: 'green' -}; - -export default Button; diff --git a/components/semantics/doc-parser/parser.ts b/components/semantics/doc-parser/parser.ts index 7019f8502c93..57ad46cba28d 100644 --- a/components/semantics/doc-parser/parser.ts +++ b/components/semantics/doc-parser/parser.ts @@ -1,9 +1,7 @@ import fs from 'fs-extra'; import type { FsCache } from '@teambit/workspace.modules.fs-cache'; import type { SourceFile } from '@teambit/component.sources'; -import type { PathOsBased } from '@teambit/toolbox.path.path'; import jsDocParse from './jsdoc'; -import reactParse from './react'; import type { Doclet } from './types'; export default async function parse(file: SourceFile, componentFsCache: FsCache): Promise { @@ -16,15 +14,7 @@ export default async function parse(file: SourceFile, componentFsCache: FsCache) } } - const results = await parseFile(file.contents.toString(), file.relative); + const results = await jsDocParse(file.contents.toString(), file.relative); await componentFsCache.saveDocsInCache(file.path, results); return results; } - -async function parseFile(data: string, filePath: PathOsBased): Promise { - const reactDocs = await reactParse(data, filePath); - if (reactDocs && Object.keys(reactDocs).length > 0) { - return reactDocs; - } - return jsDocParse(data, filePath); -} diff --git a/components/semantics/doc-parser/react/index.ts b/components/semantics/doc-parser/react/index.ts deleted file mode 100644 index 08eab2c8d75f..000000000000 --- a/components/semantics/doc-parser/react/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import parse from './react-parser'; - -export default parse; diff --git a/components/semantics/doc-parser/react/react-parser.spec.ts b/components/semantics/doc-parser/react/react-parser.spec.ts deleted file mode 100644 index e4ebc6406a4d..000000000000 --- a/components/semantics/doc-parser/react/react-parser.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { expect } from 'chai'; -import fs from 'fs-extra'; -import * as path from 'path'; - -import parser from './'; - -const fixtures = path.join(__dirname, '..', 'fixtures', 'jsdoc'); - -function parseFile(filePath: string) { - return parser(fs.readFileSync(filePath).toString(), 'my-file.js'); -} - -describe('React docs Parser', () => { - describe('parse()', () => { - describe('Invalid code', () => { - it('should returns an empty array', async () => { - const doclets = await parser('this is an invalid code', 'some-file'); - expect(doclets).to.be.undefined; - }); - }); - - describe('React Docs', () => { - let doclet; - before(async () => { - const file = path.join(fixtures, 'react/react-docs.js'); - const doclets = await parseFile(file); - // @ts-ignore - doclet = doclets[0]; - }); - it('should have properties parsed', () => { - expect(doclet).to.have.property('properties'); - expect(doclet.properties).to.be.an('array').with.lengthOf(3); - }); - it('should have methods parsed', () => { - expect(doclet).to.have.property('methods'); - expect(doclet.methods).to.be.an('array').with.lengthOf(2); - }); - it('should parse the description correctly', () => { - expect(doclet) - .to.have.property('description') - .that.is.equal('Styled button component for the rich and famous!'); - }); - it('should parse the examples correctly', () => { - expect(doclet).to.have.property('examples').that.is.an('array').with.lengthOf(1); - }); - it('should preserve the spaces in the example', () => { - const example = doclet.examples[0].raw; - expect(example).to.string(' text'); - }); - it('should parse the properties description correctly', () => { - expect(doclet).to.have.property('properties').that.is.an('array'); - expect(doclet.properties[0].description).to.equal('Button text.'); - }); - }); - describe('elevation', () => { - let doclet; - before(async () => { - const file = path.join(fixtures, 'react/elevation.tsxx'); - const doclets = await parseFile(file); - // @ts-ignore - doclet = doclets[0]; - expect(doclet).to.be.an('object'); - }); - it('should have properties parsed', () => { - expect(doclet).to.have.property('properties'); - expect(doclet.properties).to.be.an('array').with.lengthOf(1); - }); - it('should parse the description correctly', () => { - expect(doclet) - .to.have.property('description') - .that.is.equal('A wrapper resembling a physical card, grouping elements and improve readability.'); - }); - it('should parse the properties type correctly', () => { - expect(doclet).to.have.property('properties').that.is.an('array'); - expect(doclet.properties[0].type).to.equal("'none' | 'low' | 'medium' | 'high'"); - }); - }); - }); -}); diff --git a/components/semantics/doc-parser/react/react-parser.ts b/components/semantics/doc-parser/react/react-parser.ts deleted file mode 100644 index b3df546072f3..000000000000 --- a/components/semantics/doc-parser/react/react-parser.ts +++ /dev/null @@ -1,124 +0,0 @@ -import doctrine from 'doctrine'; -import * as reactDocs from 'react-docgen'; - -import { logger } from '@teambit/legacy.logger'; -import type { PathOsBased } from '@teambit/legacy.utils'; -import { pathNormalizeToLinux } from '@teambit/legacy.utils'; -import extractDataRegex from '../extract-data-regex'; -import type { Doclet } from '../types'; - -function formatProperties(props) { - const parseDescription = (description) => { - // an extra step is needed to parse the properties description correctly. without this step - // it'd show the entire tag, e.g. `@property {propTypes.string} text - Button text.` - // instead of just `text - Button text.`. - try { - const descriptionAST = doctrine.parse(description, { unwrap: true, recoverable: true, sloppy: true }); - if (descriptionAST && descriptionAST.tags[0]) return descriptionAST.tags[0].description; - } catch { - // failed to parse the react property, that's fine, it'll return the original description - } - return description; - }; - return Object.keys(props).map((name) => { - const { type, description, required, defaultValue, flowType, tsType } = props[name]; - - return { - name, - description: parseDescription(description), - required, - type: stringifyType(type || flowType || tsType), - defaultValue, - }; - }); -} - -function formatMethods(methods) { - return Object.keys(methods).map((key) => { - const { returns, modifiers, params, docblock, name } = methods[key]; - return { - name, - description: docblock, - returns, - modifiers, - params, - }; - }); -} - -function fromReactDocs({ description, displayName, props, methods }, filePath): Doclet { - return { - filePath: pathNormalizeToLinux(filePath), - name: displayName, - description, - properties: formatProperties(props), - access: 'public', - // @ts-ignore AUTO-ADDED-AFTER-MIGRATION-PLEASE-FIX! - methods: formatMethods(methods), - }; -} - -function stringifyType(prop: { name: string; value?: any; raw?: string }): string { - if (!prop) return '?'; // TODO! - - const { name } = prop; - let transformed; - - switch (name) { - default: - transformed = name; - break; - case 'func': - transformed = 'function'; - break; - case 'shape': - transformed = JSON.stringify( - Object.keys(prop.value).reduce((acc = {}, current) => { - acc[current] = stringifyType(prop.value[current]); - return acc; - }, {}) - ); - break; - case 'enum': - transformed = prop.value.map((enumProp) => enumProp.value).join(' | '); - break; - case 'instanceOf': - transformed = prop.value; - break; - case 'union': - transformed = prop.value ? prop.value.map((p) => stringifyType(p)).join(' | ') : prop.raw; - break; - case 'arrayOf': - transformed = `${stringifyType(prop.value)}[]`; - break; - } - - return transformed; -} - -export default async function parse(data: string, filePath: PathOsBased): Promise { - const doclets: Array = []; - try { - const componentsInfo = reactDocs.parse(data, reactDocs.resolver.findAllExportedComponentDefinitions, undefined, { - configFile: false, - filename: filePath, // should we use pathNormalizeToLinux(filePath) ? - }); - - if (componentsInfo) { - return componentsInfo.map((componentInfo) => { - const formatted = fromReactDocs(componentInfo, filePath); - formatted.args = []; - // this is a workaround to get the 'example' tag parsed when using react-docs - // because as of now Docgen doesn't parse @example tag, instead, it shows it inside - // the @description tag. - extractDataRegex(formatted.description, doclets, filePath, false); - formatted.description = doclets[0].description; - formatted.examples = doclets[0].examples; - return formatted; - }); - } - } catch (err: any) { - logger.trace(`failed parsing docs using docgen on path ${filePath} with error`, err); - } - return undefined; -} diff --git a/scopes/react/react/react-docs-from-schema.spec.ts b/scopes/react/react/react-docs-from-schema.spec.ts new file mode 100644 index 000000000000..10011e9d162d --- /dev/null +++ b/scopes/react/react/react-docs-from-schema.spec.ts @@ -0,0 +1,176 @@ +import { expect } from 'chai'; +import type { Location as SchemaLocation, SchemaNode } from '@teambit/semantics.entities.semantic-schema'; +import { + APISchema, + DocSchema, + ExportSchema, + InterfaceSchema, + KeywordTypeSchema, + ModuleSchema, + ParameterSchema, + TypeIntersectionSchema, + TypeLiteralSchema, + TypeRefSchema, + TypeSchema, + VariableLikeSchema, +} from '@teambit/semantics.entities.semantic-schema'; +import { ComponentID } from '@teambit/component-id'; +import { ReactSchema } from './react.schema'; +import { reactDocsFromSchema } from './react-docs-from-schema'; + +const loc: SchemaLocation = { filePath: 'index.ts', line: 0, character: 0 }; +const compId = ComponentID.fromString('org.scope/button'); + +function member(name: string, type: string, isOptional: boolean, comment?: string): VariableLikeSchema { + return new VariableLikeSchema( + loc, + name, + `${name}: ${type}`, + new KeywordTypeSchema(loc, type), + isOptional, + comment ? new DocSchema(loc, `/** ${comment} */`, comment) : undefined + ); +} + +function reactNode(name: string, propsTypeName: string, bindings?: SchemaNode[]): ReactSchema { + const props = new ParameterSchema( + loc, + 'props', + new TypeRefSchema(loc, propsTypeName), + false, + undefined, + undefined, + bindings + ); + return new ReactSchema(loc, name, new TypeRefSchema(loc, 'JSX.Element'), props); +} + +function apiSchema(exports: SchemaNode[], internals: SchemaNode[] = []): APISchema { + return new APISchema(loc, new ModuleSchema(loc, exports, internals), [], compId); +} + +describe('reactDocsFromSchema()', () => { + it('returns undefined when the component exports no react component', () => { + const api = apiSchema([new TypeSchema(loc, 'ButtonProps', new TypeLiteralSchema(loc, []), 'type ButtonProps')]); + expect(reactDocsFromSchema(api)).to.be.undefined; + }); + + it('resolves props from a type alias to an object type', () => { + const propsType = new TypeSchema( + loc, + 'ButtonProps', + new TypeLiteralSchema(loc, [member('text', 'string', true, 'the button label'), member('onClick', 'function', false)]), + 'type ButtonProps' + ); + const docs = reactDocsFromSchema(apiSchema([reactNode('Button', 'ButtonProps'), propsType])); + + expect(docs?.properties).to.have.lengthOf(2); + expect(docs?.properties[0]).to.deep.include({ + name: 'text', + type: 'string', + description: 'the button label', + required: false, + }); + expect(docs?.properties[1]).to.deep.include({ name: 'onClick', required: true }); + }); + + it('resolves props from an interface', () => { + const propsType = new InterfaceSchema(loc, 'ButtonProps', 'interface ButtonProps', [], [ + member('text', 'string', true), + ]); + const docs = reactDocsFromSchema(apiSchema([reactNode('Button', 'ButtonProps'), propsType])); + + expect(docs?.properties.map((prop) => prop.name)).to.deep.equal(['text']); + }); + + it('resolves a props type that is internal rather than exported', () => { + const propsType = new TypeSchema( + loc, + 'ButtonProps', + new TypeLiteralSchema(loc, [member('text', 'string', true)]), + 'type ButtonProps' + ); + const docs = reactDocsFromSchema(apiSchema([reactNode('Button', 'ButtonProps')], [propsType])); + + expect(docs?.properties.map((prop) => prop.name)).to.deep.equal(['text']); + }); + + it('merges the members of an intersection and ignores references it cannot resolve', () => { + // `type ButtonProps = { text?: string } & HTMLAttributes` — the second member + // belongs to an external package, so this schema says nothing about it. + const propsType = new TypeSchema( + loc, + 'ButtonProps', + new TypeIntersectionSchema(loc, [ + new TypeLiteralSchema(loc, [member('text', 'string', true)]), + new TypeRefSchema(loc, 'HTMLAttributes', undefined, 'react'), + ]), + 'type ButtonProps' + ); + const docs = reactDocsFromSchema(apiSchema([reactNode('Button', 'ButtonProps'), propsType])); + + expect(docs?.properties.map((prop) => prop.name)).to.deep.equal(['text']); + }); + + it('takes default values from the destructured parameter', () => { + const propsType = new TypeSchema( + loc, + 'ButtonProps', + new TypeLiteralSchema(loc, [member('text', 'string', true)]), + 'type ButtonProps' + ); + const binding = new VariableLikeSchema( + loc, + 'text', + 'text: string', + new KeywordTypeSchema(loc, 'string'), + true, + undefined, + "'click me'" + ); + const docs = reactDocsFromSchema(apiSchema([reactNode('Button', 'ButtonProps', [binding]), propsType])); + + expect(docs?.properties[0].defaultValue).to.deep.equal({ value: "'click me'", computed: false }); + }); + + it('unwraps export wrappers and describes the first component that has resolvable props', () => { + const propsType = new TypeSchema( + loc, + 'ButtonProps', + new TypeLiteralSchema(loc, [member('text', 'string', true)]), + 'type ButtonProps' + ); + const withoutProps = reactNode('Spacer', 'UnknownProps'); + const withProps = reactNode('Button', 'ButtonProps'); + const api = apiSchema([ + new ExportSchema(loc, 'Spacer', withoutProps), + new ExportSchema(loc, 'Button', withProps), + propsType, + ]); + + const docs = reactDocsFromSchema(api); + expect(docs?.properties.map((prop) => prop.name)).to.deep.equal(['text']); + }); + + it('describes the component even when none of them have resolvable props', () => { + const docs = reactDocsFromSchema(apiSchema([reactNode('Spacer', 'UnknownProps')])); + + expect(docs).to.not.be.undefined; + expect(docs?.properties).to.deep.equal([]); + expect(docs?.filePath).to.equal('index.ts'); + }); + + it('exposes the component doc comment as the abstract', () => { + const node = new ReactSchema( + loc, + 'Button', + new TypeRefSchema(loc, 'JSX.Element'), + undefined, + undefined, + [], + new DocSchema(loc, '/** a button */', 'a button') + ); + + expect(reactDocsFromSchema(apiSchema([node]))?.abstract).to.equal('a button'); + }); +}); diff --git a/scopes/react/react/react-docs-from-schema.ts b/scopes/react/react/react-docs-from-schema.ts new file mode 100644 index 000000000000..3ca2e23d5f6f --- /dev/null +++ b/scopes/react/react/react-docs-from-schema.ts @@ -0,0 +1,148 @@ +import type { APISchema, SchemaNode } from '@teambit/semantics.entities.semantic-schema'; +import { compact, uniqBy } from 'lodash'; + +export type ReactDocsProperty = { + name: string; + description: string; + required: boolean; + type: string; + defaultValue?: { value: string; computed: boolean }; +}; + +export type ReactDocsFromSchema = { + abstract: string; + filePath: string; + properties: ReactDocsProperty[]; +}; + +/** + * schema nodes are matched on `__schema` rather than `instanceof`, so that a duplicated copy of + * the semantic-schema module (a real possibility across the aspect graph) doesn't silently stop + * every prop from resolving. + */ +function isSchema(node: SchemaNode | undefined, schemaName: string): boolean { + return node?.__schema === schemaName; +} + +function unwrapExports(module: { exports: SchemaNode[] }): SchemaNode[] { + return module.exports.flatMap((node) => { + if (isSchema(node, 'ExportSchema')) { + const exportNode = (node as unknown as { exportNode?: SchemaNode }).exportNode; + return exportNode ? [exportNode] : []; + } + if (isSchema(node, 'ModuleSchema')) return unwrapExports(node as unknown as { exports: SchemaNode[] }); + return [node]; + }); +} + +/** + * a props type may be exported alongside the component, or declared privately in one of its files, + * so both are indexed to resolve a type reference by name. + */ +function indexByName(api: APISchema): Map { + const index = new Map(); + const add = (node: SchemaNode) => { + if (node.name && !index.has(node.name)) index.set(node.name, node); + }; + unwrapExports(api.module).forEach(add); + api.module.internals.forEach(add); + api.internals.forEach((internal) => { + unwrapExports(internal).forEach(add); + internal.internals.forEach(add); + }); + return index; +} + +/** + * resolves a props type down to the members it contributes: an inline object type, an interface, an + * alias to either, or an intersection of them. a reference that resolves to nothing contributes no + * members — the schema of one component doesn't describe types owned by another component or by an + * external package. + */ +function membersOf( + node: SchemaNode | undefined, + index: Map, + seen = new Set() +): SchemaNode[] { + if (!node || seen.has(node)) return []; + seen.add(node); + + if (isSchema(node, 'TypeRefSchema')) { + return node.name ? membersOf(index.get(node.name), index, seen) : []; + } + if (isSchema(node, 'TypeSchema')) { + return membersOf((node as unknown as { type?: SchemaNode }).type, index, seen); + } + if (isSchema(node, 'TypeLiteralSchema') || isSchema(node, 'InterfaceSchema')) { + return (node as unknown as { members: SchemaNode[] }).members; + } + if (isSchema(node, 'TypeIntersectionSchema')) { + return (node as unknown as { types: SchemaNode[] }).types.flatMap((type) => membersOf(type, index, seen)); + } + return []; +} + +/** + * default values live on the destructured parameter (`{ isTag = () => true }`) rather than on the + * props type, so they are collected separately and merged in by name. + */ +function defaultsByName(props: SchemaNode | undefined): Map { + const bindingNodes = (props as unknown as { objectBindingNodes?: SchemaNode[] } | undefined)?.objectBindingNodes; + const defaults = new Map(); + bindingNodes?.forEach((node) => { + const { name, defaultValue } = node as unknown as { name?: string; defaultValue?: string }; + if (name && defaultValue !== undefined && !defaults.has(name)) defaults.set(name, defaultValue); + }); + return defaults; +} + +function toProperty(member: SchemaNode, defaults: Map): ReactDocsProperty | undefined { + if (!member.name) return undefined; + const { type, isOptional, doc } = member as unknown as { + type?: SchemaNode; + isOptional?: boolean; + doc?: { comment?: string; raw?: string }; + }; + const defaultValue = defaults.get(member.name); + + return { + name: member.name, + description: doc?.comment || '', + required: isOptional === undefined ? false : !isOptional, + type: type ? type.toString() : member.toString(), + defaultValue: defaultValue === undefined ? undefined : { value: defaultValue, computed: false }, + }; +} + +/** + * derives the docs shown in the properties table from the component's API schema. + * + * only the first React component that resolves any props is described, which is what the docs UI + * has always rendered — it reads a single entry, not one per export. + */ +export function reactDocsFromSchema(api: APISchema): ReactDocsFromSchema | undefined { + const reactNodes = unwrapExports(api.module).filter((node) => isSchema(node, 'ReactSchema')); + if (!reactNodes.length) return undefined; + + const index = indexByName(api); + + const docsFor = (node: SchemaNode): ReactDocsFromSchema => { + const props = (node as unknown as { props?: SchemaNode }).props; + const propsType = (props as unknown as { type?: SchemaNode } | undefined)?.type; + const defaults = defaultsByName(props); + const properties = uniqBy( + compact(membersOf(propsType, index).map((member) => toProperty(member, defaults))), + 'name' + ); + const doc = (node as unknown as { doc?: { comment?: string } }).doc; + + return { + abstract: doc?.comment || '', + filePath: node.location.filePath, + properties, + }; + }; + + const allDocs = reactNodes.map(docsFor); + return allDocs.find((docs) => docs.properties.length > 0) || allDocs[0]; +} diff --git a/scopes/react/react/react.graphql.ts b/scopes/react/react/react.graphql.ts index 0ba71bd19bb9..2526cddd9415 100644 --- a/scopes/react/react/react.graphql.ts +++ b/scopes/react/react/react.graphql.ts @@ -41,7 +41,7 @@ export function reactSchema(react: ReactMain) { }; if (!component) return empty; - const docs = react.getDocs(component); + const docs = await react.getDocs(component); if (!docs) return empty; return docs; diff --git a/scopes/react/react/react.main.runtime.ts b/scopes/react/react/react.main.runtime.ts index 260db48738ef..7a097932a7e5 100644 --- a/scopes/react/react/react.main.runtime.ts +++ b/scopes/react/react/react.main.runtime.ts @@ -50,6 +50,8 @@ import { getTemplates } from './react.templates'; import { getStarters } from './react.starters'; import type { ReactAppOptions } from './apps/web/react-app-options'; import { ReactSchema } from './react.schema'; +import type { ReactDocsFromSchema } from './react-docs-from-schema'; +import { reactDocsFromSchema } from './react-docs-from-schema'; import { ReactAPITransformer } from './react.api.transformer'; import type { PrettierConfigTransformer } from '@teambit/defender.prettier.config-mutator'; @@ -124,7 +126,9 @@ export class ReactMain { private dependencyResolver: DependencyResolverMain, - private logger: Logger + private logger: Logger, + + private schema?: SchemaMain ) {} readonly env = this.reactEnv; @@ -395,21 +399,34 @@ export class ReactMain { } /** - * returns doc adjusted specifically for react components. + * dedupes concurrent extractions of the same component. nothing is retained once an extraction + * settles, so a workspace component is never described from a stale schema. */ - getDocs(component: Component) { - const docsArray = component.state._consumer.docs; - if (!docsArray || !docsArray[0]) { - return null; - } - - const docs = docsArray[0]; + private docsInflight = new Map>(); - return { - abstract: docs.description, - filePath: docs.filePath, - properties: docs.properties, - }; + /** + * returns doc adjusted specifically for react components, derived from the component's API schema. + */ + async getDocs(component: Component): Promise { + if (!this.schema) return null; + + const key = component.id.toString(); + const inflight = this.docsInflight.get(key); + if (inflight) return inflight; + + const promise = this.schema + .getSchema(component) + .then((api) => reactDocsFromSchema(api) || null) + .catch((err) => { + this.logger.debug(`react.getDocs, failed extracting the schema of ${key}`, err); + return null; + }) + .finally(() => { + this.docsInflight.delete(key); + }); + + this.docsInflight.set(key, promise); + return promise; } static runtime = MainRuntime; @@ -469,7 +486,7 @@ export class ReactMain { CompilerAspect.id ); const appType = new ReactAppType('react-app', reactEnv, logger, dependencyResolver); - const react = new ReactMain(reactEnv, envs, application, appType, dependencyResolver, logger); + const react = new ReactMain(reactEnv, envs, application, appType, dependencyResolver, logger, schemaMain); graphql.register(() => reactSchema(react)); envs.registerEnv(reactEnv); if (generator) { diff --git a/workspace.jsonc b/workspace.jsonc index 57029a208c79..b5537b9e84b8 100644 --- a/workspace.jsonc +++ b/workspace.jsonc @@ -604,7 +604,6 @@ "query-string": "7.0.0", "react-animate-height": "3.2.3", "react-dev-utils": "12.0.1", - "react-docgen": "5.3.1", "react-error-boundary": "^3.0.0", "react-error-overlay": "6.0.9", "react-syntax-highlighter": "^15.6.1", From 89bbe61df2ceabeccded9098b098941506b5ea70 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Fri, 28 Aug 2026 09:58:30 -0400 Subject: [PATCH 2/6] refactor(semantic-schema, react): resolve props through the schema's own generics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `react-docs-from-schema` knew the internals of seven node types — it matched `__schema` strings for Export/Module/TypeRef/Type/TypeLiteral/Interface/ TypeIntersection and cast through `as unknown as` to reach their fields. That coupled the React aspect to the shape of every schema node it might meet and sidestepped the polymorphic design of `SchemaNode`. Each node now owns the answer to "which members do you contribute as an object-like type", the same way it owns `getNodes()`/`toString()`/`diff()`: - `SchemaNode.getMembers(context)` — default: none. Interface and type literal return their members (interfaces append what they inherit via `extends`); intersection combines its parts; alias, parentheses and export wrappers delegate; a type reference resolves through `context.resolveRef`. A visited set makes self-referencing types terminate. - `ModuleSchema.listExports()` / `listDeclarations()` — non-mutating views with export wrappers and namespaces unwrapped (`flatExportsRecursively` mutates). - `APISchema.findDeclaration()` / `resolveRef()` / `getMembersOf()` — resolve references by name within the component; references to other components or packages resolve to nothing, since their declarations aren't in this schema. - `ParameterSchema.getBindingDefaults()` — defaults from destructured bindings. - `VariableLikeSchema.isVariableLikeSchema()` / `ReactSchema.isReactSchema()` guards, in the package's existing `isTypeRefSchema` idiom. The React mapper is now ~20 lines that only know `ReactSchema` and the generic API. Inherited props (`interface ButtonProps extends BaseProps`) are described too, which neither react-docgen nor the previous mapper did. 18 new specs for the entity, 10 for the mapper (one new, for inheritance). Note: `bit test` can't run `components/*` specs in this workspace locally (pre-existing — same failure on `doc-parser`); they run in CI's capsules. Co-Authored-By: Claude Fable 5 --- .../entities/semantic-schema/api-schema.ts | 32 +++ .../semantic-schema/schema-members.spec.ts | 184 ++++++++++++++++++ .../entities/semantic-schema/schema-node.ts | 39 +++- .../semantic-schema/schemas/export.ts | 6 +- .../semantic-schema/schemas/interface.ts | 10 +- .../semantic-schema/schemas/module.ts | 23 ++- .../semantic-schema/schemas/parameter.ts | 15 ++ .../schemas/parenthesized-type.ts | 6 +- .../schemas/type-intersection.ts | 6 +- .../semantic-schema/schemas/type-literal.ts | 4 + .../semantic-schema/schemas/type-ref.ts | 9 +- .../entities/semantic-schema/schemas/type.ts | 6 +- .../semantic-schema/schemas/variable-like.ts | 4 + .../react/react-docs-from-schema.spec.ts | 36 +++- scopes/react/react/react-docs-from-schema.ts | 123 ++---------- scopes/react/react/react.schema.ts | 4 + 16 files changed, 389 insertions(+), 118 deletions(-) create mode 100644 components/entities/semantic-schema/schema-members.spec.ts diff --git a/components/entities/semantic-schema/api-schema.ts b/components/entities/semantic-schema/api-schema.ts index 50e0ddd16cfa..104df8102756 100644 --- a/components/entities/semantic-schema/api-schema.ts +++ b/components/entities/semantic-schema/api-schema.ts @@ -1,6 +1,7 @@ import chalk from 'chalk'; import { ComponentID } from '@teambit/component-id'; import { ExportSchema, ModuleSchema } from './schemas'; +import type { TypeRefSchema } from './schemas'; import type { SchemaLocation } from './schema-node'; import { SchemaNode } from './schema-node'; import { TagName } from './schemas/docs/tag'; @@ -134,6 +135,37 @@ export class APISchema extends SchemaNode { return sectionNameMap[constructorName] || constructorName; } + /** + * every declaration of the component: the index module's, then the internal modules'. + */ + listDeclarations(): SchemaNode[] { + return [this.module, ...this.internals].flatMap((module) => module.listDeclarations()); + } + + /** + * finds a declaration of this component by name, exported or internal. + */ + findDeclaration(name: string): SchemaNode | undefined { + return this.listDeclarations().find((node) => node.name === name); + } + + /** + * resolves a type reference to the declaration it points at. references to other components or to + * packages resolve to nothing: their declarations are not part of this schema. + */ + resolveRef(ref: TypeRefSchema): SchemaNode | undefined { + if (!ref.isFromThisComponent()) return undefined; + return this.findDeclaration(ref.name); + } + + /** + * the members an object-like type contributes, resolving references within this component. + * see `SchemaNode.getMembers()`. + */ + getMembersOf(node: SchemaNode): SchemaNode[] { + return node.getMembers({ resolveRef: (ref) => this.resolveRef(ref) }); + } + listSignatures() { return this.module.exports.map((exp) => exp.signature); } diff --git a/components/entities/semantic-schema/schema-members.spec.ts b/components/entities/semantic-schema/schema-members.spec.ts new file mode 100644 index 000000000000..6f7c1312675c --- /dev/null +++ b/components/entities/semantic-schema/schema-members.spec.ts @@ -0,0 +1,184 @@ +import { expect } from 'chai'; +import { ComponentID } from '@teambit/component-id'; +import type { SchemaLocation, SchemaNode } from './schema-node'; +import { APISchema } from './api-schema'; +import { + ExportSchema, + ExpressionWithTypeArgumentsSchema, + InferenceTypeSchema, + InterfaceSchema, + KeywordTypeSchema, + ModuleSchema, + ParameterSchema, + ParenthesizedTypeSchema, + TypeIntersectionSchema, + TypeLiteralSchema, + TypeRefSchema, + TypeSchema, + VariableLikeSchema, +} from './schemas'; + +const loc: SchemaLocation = { filePath: 'index.ts', line: 0, character: 0 }; +const compId = ComponentID.fromString('org.scope/button'); + +function member(name: string, type = 'string', isOptional = true): VariableLikeSchema { + return new VariableLikeSchema(loc, name, `${name}: ${type}`, new KeywordTypeSchema(loc, type), isOptional); +} + +function iface(name: string, members: SchemaNode[], extendsNodes: ExpressionWithTypeArgumentsSchema[] = []) { + return new InterfaceSchema(loc, name, `interface ${name}`, extendsNodes, members); +} + +function literal(...members: SchemaNode[]) { + return new TypeLiteralSchema(loc, members); +} + +function extendsRef(name: string) { + return new ExpressionWithTypeArgumentsSchema([], new TypeRefSchema(loc, name), name, loc); +} + +const names = (nodes: SchemaNode[]) => nodes.map((node) => node.name); + +describe('SchemaNode.getMembers()', () => { + it('returns the members of an interface', () => { + expect(names(iface('Props', [member('a'), member('b')]).getMembers())).to.deep.equal(['a', 'b']); + }); + + it('returns the members of a type literal', () => { + expect(names(literal(member('a')).getMembers())).to.deep.equal(['a']); + }); + + it('contributes nothing for a type that is not object-like', () => { + expect(new KeywordTypeSchema(loc, 'string').getMembers()).to.deep.equal([]); + }); + + it('combines the members of an intersection', () => { + const intersection = new TypeIntersectionSchema(loc, [literal(member('a')), iface('B', [member('b')])]); + expect(names(intersection.getMembers())).to.deep.equal(['a', 'b']); + }); + + it('follows a type alias and parentheses to the underlying type', () => { + const alias = new TypeSchema(loc, 'Props', new ParenthesizedTypeSchema(loc, literal(member('a'))), 'type Props'); + expect(names(alias.getMembers())).to.deep.equal(['a']); + }); + + it('follows an export wrapper to the exported declaration', () => { + expect(names(new ExportSchema(loc, 'Props', iface('Props', [member('a')])).getMembers())).to.deep.equal(['a']); + }); + + it('resolves a reference through the context, and contributes nothing without one', () => { + const ref = new TypeRefSchema(loc, 'Props'); + const target = iface('Props', [member('a')]); + expect(names(ref.getMembers({ resolveRef: () => target }))).to.deep.equal(['a']); + expect(ref.getMembers()).to.deep.equal([]); + }); + + it('includes the members an interface inherits, after its own', () => { + const base = iface('Base', [member('a')]); + const derived = iface('Props', [member('b')], [extendsRef('Base')]); + const resolveRef = (ref: TypeRefSchema) => (ref.name === 'Base' ? base : undefined); + expect(names(derived.getMembers({ resolveRef }))).to.deep.equal(['b', 'a']); + }); + + it('terminates on a self-referencing type', () => { + const self = new TypeRefSchema(loc, 'Props'); + const alias = new TypeSchema( + loc, + 'Props', + new TypeIntersectionSchema(loc, [self, literal(member('a'))]), + 'type Props' + ); + expect(names(alias.getMembers({ resolveRef: () => alias }))).to.deep.equal(['a']); + }); +}); + +describe('ModuleSchema.listExports() / listDeclarations()', () => { + const namespace = new ModuleSchema(loc, [iface('Inner', [])], []); + namespace.namespace = 'ns'; + const mod = new ModuleSchema( + loc, + [new ExportSchema(loc, 'Props', iface('Props', [])), namespace, iface('Other', [])], + [iface('Internal', [])] + ); + + it('lists exports with wrappers and nested namespaces unwrapped', () => { + expect(names(mod.listExports())).to.deep.equal(['Props', 'Inner', 'Other']); + }); + + it('lists internals after the exports', () => { + expect(names(mod.listDeclarations())).to.deep.equal(['Props', 'Inner', 'Other', 'Internal']); + }); + + it('does not mutate the module', () => { + mod.listExports(); + expect(mod.exports).to.have.lengthOf(3); + expect(mod.exports[0]).to.be.instanceOf(ExportSchema); + }); +}); + +describe('APISchema.getMembersOf()', () => { + function api(exports: SchemaNode[], internals: SchemaNode[] = [], internalModules: ModuleSchema[] = []) { + return new APISchema(loc, new ModuleSchema(loc, exports, internals), internalModules, compId); + } + + it('resolves references to exported and internal declarations of the component', () => { + const schema = api( + [new ExportSchema(loc, 'Props', iface('Props', [member('a')]))], + [new TypeSchema(loc, 'FileInternal', literal(member('b')), 'type FileInternal')], + [new ModuleSchema(loc, [], [new TypeSchema(loc, 'ModuleInternal', literal(member('c')), 'type ModuleInternal')])] + ); + const props = new TypeIntersectionSchema(loc, [ + new TypeRefSchema(loc, 'Props'), + new TypeRefSchema(loc, 'FileInternal'), + new TypeRefSchema(loc, 'ModuleInternal'), + ]); + expect(names(schema.getMembersOf(props))).to.deep.equal(['a', 'b', 'c']); + }); + + it('resolves through an alias of a reference', () => { + const schema = api([ + new TypeSchema(loc, 'Props', new TypeRefSchema(loc, 'Base'), 'type Props'), + iface('Base', [member('a')]), + ]); + expect(names(schema.getMembersOf(new TypeRefSchema(loc, 'Props')))).to.deep.equal(['a']); + }); + + it('does not resolve references to other components or packages, even with a matching name', () => { + const schema = api([iface('Props', [member('a')])]); + const fromComponent = new TypeRefSchema(loc, 'Props', ComponentID.fromString('org.scope/other')); + const fromPackage = new TypeRefSchema(loc, 'Props', undefined, 'react'); + expect(schema.getMembersOf(fromComponent)).to.deep.equal([]); + expect(schema.getMembersOf(fromPackage)).to.deep.equal([]); + }); + + it('contributes nothing for an unknown reference', () => { + expect(api([]).getMembersOf(new TypeRefSchema(loc, 'Missing'))).to.deep.equal([]); + }); +}); + +describe('ParameterSchema.getBindingDefaults()', () => { + it('collects default values from destructured bindings, whatever node describes them', () => { + const param = new ParameterSchema(loc, 'props', new TypeRefSchema(loc, 'Props'), false, undefined, undefined, [ + new InferenceTypeSchema(loc, 'number', 'size', '32'), + new VariableLikeSchema( + loc, + 'label', + 'label: string', + new KeywordTypeSchema(loc, 'string'), + true, + undefined, + "'hi'" + ), + new InferenceTypeSchema(loc, 'string', 'other'), + ]); + expect([...param.getBindingDefaults()]).to.deep.equal([ + ['size', '32'], + ['label', "'hi'"], + ]); + }); + + it('is empty for a parameter without bindings', () => { + const param = new ParameterSchema(loc, 'props', new TypeRefSchema(loc, 'Props'), false); + expect(param.getBindingDefaults().size).to.equal(0); + }); +}); diff --git a/components/entities/semantic-schema/schema-node.ts b/components/entities/semantic-schema/schema-node.ts index f0568bcbc626..631c1dd5c0bd 100644 --- a/components/entities/semantic-schema/schema-node.ts +++ b/components/entities/semantic-schema/schema-node.ts @@ -1,9 +1,24 @@ import { pickBy } from 'lodash'; import pluralize from 'pluralize'; -import type { DocSchema } from './schemas'; +import type { DocSchema, TypeRefSchema } from './schemas'; import type { SchemaChangeFact } from './schema-diff'; import { deepEqualNoLocation, diffDoc } from './schema-diff'; +/** + * context for `SchemaNode.getMembers()`. + */ +export type GetMembersContext = { + /** + * resolves a type reference to the declaration it points at, e.g. by name within an `APISchema`. + * without it, references contribute no members. + */ + resolveRef?: (ref: TypeRefSchema) => SchemaNode | undefined; + /** + * nodes already being expanded in this traversal. guards against self-referencing types. + */ + visited?: Set; +}; + export interface ISchemaNode { __schema: string; name?: string; @@ -17,6 +32,7 @@ export interface ISchemaNode { getNodes(): SchemaNode[]; findNode(predicate: (node: SchemaNode) => boolean, visitedNodes?: Set): SchemaNode | undefined; getAllNodesRecursively(visitedNodes?: Set): SchemaNode[]; + getMembers(context?: GetMembersContext): SchemaNode[]; diff(other: SchemaNode): SchemaChangeFact[]; } @@ -88,6 +104,27 @@ export abstract class SchemaNode implements ISchemaNode { return undefined; } + /** + * the members this node contributes when it describes an object-like type: the members of an interface or + * a type literal, the combined members of an intersection, or the members of whatever an alias, parentheses + * or a type reference resolve to. nodes that don't describe an object type contribute none. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getMembers(context: GetMembersContext = {}): SchemaNode[] { + return []; + } + + /** + * the members of a child node, expanded at most once per traversal so self-referencing types terminate. + */ + protected static membersOf(node: SchemaNode | undefined, context: GetMembersContext): SchemaNode[] { + if (!node) return []; + const visited = context.visited || new Set(); + if (visited.has(node)) return []; + visited.add(node); + return node.getMembers({ ...context, visited }); + } + /** * Compute neutral change facts between this node and another node of the same type. * Subclasses should override with type-specific comparison logic. diff --git a/components/entities/semantic-schema/schemas/export.ts b/components/entities/semantic-schema/schemas/export.ts index a63222bc2ac7..d145ec233aca 100644 --- a/components/entities/semantic-schema/schemas/export.ts +++ b/components/entities/semantic-schema/schemas/export.ts @@ -1,4 +1,4 @@ -import type { SchemaLocation } from '../schema-node'; +import type { GetMembersContext, SchemaLocation } from '../schema-node'; import { SchemaNode } from '../schema-node'; import { DocSchema } from './docs'; import { SchemaRegistry } from '../schema-registry'; @@ -23,6 +23,10 @@ export class ExportSchema extends SchemaNode { this.signature = exportNode.signature || this.toFullSignature(); } + getMembers(context: GetMembersContext = {}) { + return SchemaNode.membersOf(this.exportNode, context); + } + toString(options?: { color?: boolean }): string { let signature = ''; diff --git a/components/entities/semantic-schema/schemas/interface.ts b/components/entities/semantic-schema/schemas/interface.ts index bd018219dc93..2c732f2e5862 100644 --- a/components/entities/semantic-schema/schemas/interface.ts +++ b/components/entities/semantic-schema/schemas/interface.ts @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import type { SchemaLocation } from '../schema-node'; +import type { GetMembersContext, SchemaLocation } from '../schema-node'; import { SchemaNode } from '../schema-node'; import type { SchemaChangeFact } from '../schema-diff'; import { diffMembers } from '../schema-diff-members'; @@ -29,6 +29,14 @@ export class InterfaceSchema extends SchemaNode { return this.members; } + /** + * own members first, then the ones inherited through `extends` — so an override shadows what it overrides. + */ + getMembers(context: GetMembersContext = {}) { + const inherited = (this.extendsNodes || []).flatMap((node) => SchemaNode.membersOf(node.expression, context)); + return [...this.members, ...inherited]; + } + toString(options?: { color?: boolean }): string { const boldUnderline = options?.color ? chalk.bold.underline : (str: string) => str; const membersStr = this.members.map((m) => `* ${m.toString(options)}`).join('\n'); diff --git a/components/entities/semantic-schema/schemas/module.ts b/components/entities/semantic-schema/schemas/module.ts index d45299810063..1e60d5e5cf61 100644 --- a/components/entities/semantic-schema/schemas/module.ts +++ b/components/entities/semantic-schema/schemas/module.ts @@ -2,7 +2,7 @@ import chalk from 'chalk'; import type { SchemaLocation } from '../schema-node'; import { SchemaNode } from '../schema-node'; import { SchemaRegistry } from '../schema-registry'; -import type { ExportSchema } from './export'; +import { ExportSchema } from './export'; export class ModuleSchema extends SchemaNode { // exports could either be re exports (export declarations) or nodes with export modifier @@ -24,6 +24,27 @@ export class ModuleSchema extends SchemaNode { return [...this.exports, ...this.internals]; } + /** + * the declarations this module exports, with export wrappers and nested namespaces unwrapped. + * unlike `flatExportsRecursively()`, this doesn't mutate the module. + */ + listExports(): SchemaNode[] { + return this.exports.flatMap((node) => ModuleSchema.unwrap(node)); + } + + /** + * every declaration in this module: the exports, then the internals. + */ + listDeclarations(): SchemaNode[] { + return [...this.listExports(), ...this.internals.flatMap((node) => ModuleSchema.unwrap(node))]; + } + + private static unwrap(node: SchemaNode): SchemaNode[] { + if (ExportSchema.isExportSchema(node)) return ModuleSchema.unwrap(node.exportNode); + if (ModuleSchema.isModuleSchema(node)) return node.listExports(); + return [node]; + } + flatExportsRecursively() { this.exports = this.exports.reduce( (acc, exp) => { diff --git a/components/entities/semantic-schema/schemas/parameter.ts b/components/entities/semantic-schema/schemas/parameter.ts index 6ce772e31c49..14b1e66dc527 100644 --- a/components/entities/semantic-schema/schemas/parameter.ts +++ b/components/entities/semantic-schema/schemas/parameter.ts @@ -1,6 +1,8 @@ import type { SchemaLocation } from '../schema-node'; import { SchemaNode } from '../schema-node'; import { SchemaRegistry } from '../schema-registry'; +import { InferenceTypeSchema } from './inference-type'; +import { VariableLikeSchema } from './variable-like'; export class ParameterSchema extends SchemaNode { readonly type: T; @@ -10,6 +12,19 @@ export class ParameterSchema extends SchemaNo return [this.type, ...(this.objectBindingNodes || [])]; } + /** + * default values declared on the parameter's destructured bindings, by binding name — e.g. `{ size = 32 }`. + */ + getBindingDefaults(): Map { + const defaults = new Map(); + this.objectBindingNodes?.forEach((node) => { + if (!(node instanceof InferenceTypeSchema || node instanceof VariableLikeSchema)) return; + if (!node.name || node.defaultValue === undefined || defaults.has(node.name)) return; + defaults.set(node.name, node.defaultValue); + }); + return defaults; + } + constructor( readonly location: SchemaLocation, readonly name: string, diff --git a/components/entities/semantic-schema/schemas/parenthesized-type.ts b/components/entities/semantic-schema/schemas/parenthesized-type.ts index c493b59d8fe5..f6d04f8b4785 100644 --- a/components/entities/semantic-schema/schemas/parenthesized-type.ts +++ b/components/entities/semantic-schema/schemas/parenthesized-type.ts @@ -1,4 +1,4 @@ -import type { SchemaLocation } from '../schema-node'; +import type { GetMembersContext, SchemaLocation } from '../schema-node'; import { SchemaNode } from '../schema-node'; import { SchemaRegistry } from '../schema-registry'; @@ -20,6 +20,10 @@ export class ParenthesizedTypeSchema extends SchemaNode { return [this.type]; } + getMembers(context: GetMembersContext = {}) { + return SchemaNode.membersOf(this.type, context); + } + toString(options?: { color?: boolean }): string { return `(${this.type.toString(options)})`; } diff --git a/components/entities/semantic-schema/schemas/type-intersection.ts b/components/entities/semantic-schema/schemas/type-intersection.ts index 7e0e89c4544e..62482eba7aea 100644 --- a/components/entities/semantic-schema/schemas/type-intersection.ts +++ b/components/entities/semantic-schema/schemas/type-intersection.ts @@ -1,4 +1,4 @@ -import type { SchemaLocation } from '../schema-node'; +import type { GetMembersContext, SchemaLocation } from '../schema-node'; import { SchemaNode } from '../schema-node'; import { SchemaRegistry } from '../schema-registry'; @@ -17,6 +17,10 @@ export class TypeIntersectionSchema extends SchemaNode { return this.types; } + getMembers(context: GetMembersContext = {}) { + return this.types.flatMap((type) => SchemaNode.membersOf(type, context)); + } + toString(options?: { color?: boolean }) { return `${this.types.map((type) => type.toString(options)).join(' & ')}`; } diff --git a/components/entities/semantic-schema/schemas/type-literal.ts b/components/entities/semantic-schema/schemas/type-literal.ts index 083779c530fa..28e6f258a141 100644 --- a/components/entities/semantic-schema/schemas/type-literal.ts +++ b/components/entities/semantic-schema/schemas/type-literal.ts @@ -20,6 +20,10 @@ export class TypeLiteralSchema extends SchemaNode { return this.members; } + getMembers() { + return this.members; + } + toString() { return `{ ${this.members.map((type) => type.toString()).join('; ')} }`; } diff --git a/components/entities/semantic-schema/schemas/type-ref.ts b/components/entities/semantic-schema/schemas/type-ref.ts index dc323afa9f2f..0806b1e1ba5e 100644 --- a/components/entities/semantic-schema/schemas/type-ref.ts +++ b/components/entities/semantic-schema/schemas/type-ref.ts @@ -1,6 +1,6 @@ import { ComponentID } from '@teambit/component'; import chalk from 'chalk'; -import type { SchemaLocation } from '../schema-node'; +import type { GetMembersContext, SchemaLocation } from '../schema-node'; import { SchemaNode } from '../schema-node'; import { SchemaRegistry } from '../schema-registry'; @@ -57,6 +57,13 @@ export class TypeRefSchema extends SchemaNode { return this.typeArgs || []; } + /** + * the members of the declaration this reference points at, as resolved by the context. + */ + getMembers(context: GetMembersContext = {}) { + return SchemaNode.membersOf(context.resolveRef?.(this), context); + } + withTypeArgs(typeArgs: SchemaNode[]) { this.typeArgs = typeArgs; return this; diff --git a/components/entities/semantic-schema/schemas/type.ts b/components/entities/semantic-schema/schemas/type.ts index 1505ad576e61..75bc7be8bc41 100644 --- a/components/entities/semantic-schema/schemas/type.ts +++ b/components/entities/semantic-schema/schemas/type.ts @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import type { SchemaLocation } from '../schema-node'; +import type { GetMembersContext, SchemaLocation } from '../schema-node'; import { SchemaNode } from '../schema-node'; import type { SchemaChangeFact } from '../schema-diff'; import { typesAreSemanticallyEqual, diffDoc } from '../schema-diff'; @@ -44,6 +44,10 @@ export class TypeSchema extends SchemaNode { return [this.type]; } + getMembers(context: GetMembersContext = {}) { + return SchemaNode.membersOf(this.type, context); + } + toObject() { return { ...super.toObject(), diff --git a/components/entities/semantic-schema/schemas/variable-like.ts b/components/entities/semantic-schema/schemas/variable-like.ts index 9a5049799a44..755e63580554 100644 --- a/components/entities/semantic-schema/schemas/variable-like.ts +++ b/components/entities/semantic-schema/schemas/variable-like.ts @@ -66,6 +66,10 @@ export class VariableLikeSchema extends SchemaNode { }; } + static isVariableLikeSchema(node: SchemaNode): node is VariableLikeSchema { + return node.__schema === 'VariableLikeSchema'; + } + diff(other: SchemaNode): SchemaChangeFact[] { if (!(other instanceof VariableLikeSchema)) return super.diff(other); const facts: SchemaChangeFact[] = []; diff --git a/scopes/react/react/react-docs-from-schema.spec.ts b/scopes/react/react/react-docs-from-schema.spec.ts index 10011e9d162d..54197ecee3ad 100644 --- a/scopes/react/react/react-docs-from-schema.spec.ts +++ b/scopes/react/react/react-docs-from-schema.spec.ts @@ -4,6 +4,7 @@ import { APISchema, DocSchema, ExportSchema, + ExpressionWithTypeArgumentsSchema, InterfaceSchema, KeywordTypeSchema, ModuleSchema, @@ -59,7 +60,10 @@ describe('reactDocsFromSchema()', () => { const propsType = new TypeSchema( loc, 'ButtonProps', - new TypeLiteralSchema(loc, [member('text', 'string', true, 'the button label'), member('onClick', 'function', false)]), + new TypeLiteralSchema(loc, [ + member('text', 'string', true, 'the button label'), + member('onClick', 'function', false), + ]), 'type ButtonProps' ); const docs = reactDocsFromSchema(apiSchema([reactNode('Button', 'ButtonProps'), propsType])); @@ -75,9 +79,13 @@ describe('reactDocsFromSchema()', () => { }); it('resolves props from an interface', () => { - const propsType = new InterfaceSchema(loc, 'ButtonProps', 'interface ButtonProps', [], [ - member('text', 'string', true), - ]); + const propsType = new InterfaceSchema( + loc, + 'ButtonProps', + 'interface ButtonProps', + [], + [member('text', 'string', true)] + ); const docs = reactDocsFromSchema(apiSchema([reactNode('Button', 'ButtonProps'), propsType])); expect(docs?.properties.map((prop) => prop.name)).to.deep.equal(['text']); @@ -160,6 +168,26 @@ describe('reactDocsFromSchema()', () => { expect(docs?.filePath).to.equal('index.ts'); }); + it('includes the props an interface inherits through `extends`', () => { + const base = new InterfaceSchema( + loc, + 'BaseProps', + 'interface BaseProps', + [], + [member('className', 'string', true)] + ); + const propsType = new InterfaceSchema( + loc, + 'ButtonProps', + 'interface ButtonProps extends BaseProps', + [new ExpressionWithTypeArgumentsSchema([], new TypeRefSchema(loc, 'BaseProps'), 'BaseProps', loc)], + [member('text', 'string', true)] + ); + const docs = reactDocsFromSchema(apiSchema([reactNode('Button', 'ButtonProps'), propsType, base])); + + expect(docs?.properties.map((prop) => prop.name)).to.deep.equal(['text', 'className']); + }); + it('exposes the component doc comment as the abstract', () => { const node = new ReactSchema( loc, diff --git a/scopes/react/react/react-docs-from-schema.ts b/scopes/react/react/react-docs-from-schema.ts index 3ca2e23d5f6f..feeb8ee97d0d 100644 --- a/scopes/react/react/react-docs-from-schema.ts +++ b/scopes/react/react/react-docs-from-schema.ts @@ -1,5 +1,7 @@ import type { APISchema, SchemaNode } from '@teambit/semantics.entities.semantic-schema'; +import { VariableLikeSchema } from '@teambit/semantics.entities.semantic-schema'; import { compact, uniqBy } from 'lodash'; +import { ReactSchema } from './react.schema'; export type ReactDocsProperty = { name: string; @@ -15,131 +17,40 @@ export type ReactDocsFromSchema = { properties: ReactDocsProperty[]; }; -/** - * schema nodes are matched on `__schema` rather than `instanceof`, so that a duplicated copy of - * the semantic-schema module (a real possibility across the aspect graph) doesn't silently stop - * every prop from resolving. - */ -function isSchema(node: SchemaNode | undefined, schemaName: string): boolean { - return node?.__schema === schemaName; -} - -function unwrapExports(module: { exports: SchemaNode[] }): SchemaNode[] { - return module.exports.flatMap((node) => { - if (isSchema(node, 'ExportSchema')) { - const exportNode = (node as unknown as { exportNode?: SchemaNode }).exportNode; - return exportNode ? [exportNode] : []; - } - if (isSchema(node, 'ModuleSchema')) return unwrapExports(node as unknown as { exports: SchemaNode[] }); - return [node]; - }); -} - -/** - * a props type may be exported alongside the component, or declared privately in one of its files, - * so both are indexed to resolve a type reference by name. - */ -function indexByName(api: APISchema): Map { - const index = new Map(); - const add = (node: SchemaNode) => { - if (node.name && !index.has(node.name)) index.set(node.name, node); - }; - unwrapExports(api.module).forEach(add); - api.module.internals.forEach(add); - api.internals.forEach((internal) => { - unwrapExports(internal).forEach(add); - internal.internals.forEach(add); - }); - return index; -} - -/** - * resolves a props type down to the members it contributes: an inline object type, an interface, an - * alias to either, or an intersection of them. a reference that resolves to nothing contributes no - * members — the schema of one component doesn't describe types owned by another component or by an - * external package. - */ -function membersOf( - node: SchemaNode | undefined, - index: Map, - seen = new Set() -): SchemaNode[] { - if (!node || seen.has(node)) return []; - seen.add(node); - - if (isSchema(node, 'TypeRefSchema')) { - return node.name ? membersOf(index.get(node.name), index, seen) : []; - } - if (isSchema(node, 'TypeSchema')) { - return membersOf((node as unknown as { type?: SchemaNode }).type, index, seen); - } - if (isSchema(node, 'TypeLiteralSchema') || isSchema(node, 'InterfaceSchema')) { - return (node as unknown as { members: SchemaNode[] }).members; - } - if (isSchema(node, 'TypeIntersectionSchema')) { - return (node as unknown as { types: SchemaNode[] }).types.flatMap((type) => membersOf(type, index, seen)); - } - return []; -} - -/** - * default values live on the destructured parameter (`{ isTag = () => true }`) rather than on the - * props type, so they are collected separately and merged in by name. - */ -function defaultsByName(props: SchemaNode | undefined): Map { - const bindingNodes = (props as unknown as { objectBindingNodes?: SchemaNode[] } | undefined)?.objectBindingNodes; - const defaults = new Map(); - bindingNodes?.forEach((node) => { - const { name, defaultValue } = node as unknown as { name?: string; defaultValue?: string }; - if (name && defaultValue !== undefined && !defaults.has(name)) defaults.set(name, defaultValue); - }); - return defaults; -} - function toProperty(member: SchemaNode, defaults: Map): ReactDocsProperty | undefined { if (!member.name) return undefined; - const { type, isOptional, doc } = member as unknown as { - type?: SchemaNode; - isOptional?: boolean; - doc?: { comment?: string; raw?: string }; - }; const defaultValue = defaults.get(member.name); + const shape = VariableLikeSchema.isVariableLikeSchema(member) + ? { type: member.type.toString(), required: !member.isOptional } + : { type: member.toString(), required: false }; return { name: member.name, - description: doc?.comment || '', - required: isOptional === undefined ? false : !isOptional, - type: type ? type.toString() : member.toString(), + description: member.doc?.comment || '', + ...shape, defaultValue: defaultValue === undefined ? undefined : { value: defaultValue, computed: false }, }; } /** - * derives the docs shown in the properties table from the component's API schema. + * derives the docs shown in the properties table from the component's API schema: the exported React + * components, and the members of their props type as the schema resolves them. * - * only the first React component that resolves any props is described, which is what the docs UI - * has always rendered — it reads a single entry, not one per export. + * only the first component that resolves any props is described, which is what the docs UI has always + * rendered — it reads a single entry, not one per export. */ export function reactDocsFromSchema(api: APISchema): ReactDocsFromSchema | undefined { - const reactNodes = unwrapExports(api.module).filter((node) => isSchema(node, 'ReactSchema')); + const reactNodes = api.module.listExports().filter(ReactSchema.isReactSchema); if (!reactNodes.length) return undefined; - const index = indexByName(api); - - const docsFor = (node: SchemaNode): ReactDocsFromSchema => { - const props = (node as unknown as { props?: SchemaNode }).props; - const propsType = (props as unknown as { type?: SchemaNode } | undefined)?.type; - const defaults = defaultsByName(props); - const properties = uniqBy( - compact(membersOf(propsType, index).map((member) => toProperty(member, defaults))), - 'name' - ); - const doc = (node as unknown as { doc?: { comment?: string } }).doc; + const docsFor = (node: ReactSchema): ReactDocsFromSchema => { + const members = node.props ? api.getMembersOf(node.props.type) : []; + const defaults = node.props?.getBindingDefaults() || new Map(); return { - abstract: doc?.comment || '', + abstract: node.doc?.comment || '', filePath: node.location.filePath, - properties, + properties: uniqBy(compact(members.map((member) => toProperty(member, defaults))), 'name'), }; }; diff --git a/scopes/react/react/react.schema.ts b/scopes/react/react/react.schema.ts index 45898d74abed..30663e3188a0 100644 --- a/scopes/react/react/react.schema.ts +++ b/scopes/react/react/react.schema.ts @@ -97,6 +97,10 @@ export class ReactSchema extends SchemaNode { }; } + static isReactSchema(node: SchemaNode): node is ReactSchema { + return node.__schema === ReactSchema.name; + } + static fromObject(obj: Record): ReactSchema { const location = obj.location; const name = obj.name; From ab51bb79342d588e85ba28b25c22290a81e0146c Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Fri, 28 Aug 2026 10:16:16 -0400 Subject: [PATCH 3/6] fix(semantic-schema): resolve file-internal refs within their file; unions contribute members - `APISchema.resolveRef()`: a reference that carries `internalFilePath` only resolves to a declaration in that file, so same-named declarations in other files of the component are never mistaken for it. Refs to other components or packages already resolved to nothing. - `TypeUnionSchema.getMembers()`: a union of object types contributes the members of every alternative, in order; a member declared by only some alternatives is listed as the first alternative declares it. Previously a union props type rendered an empty table. Specs: entity 20 (+2), mapper 11 (+1). Co-Authored-By: Claude Fable 5 --- .../entities/semantic-schema/api-schema.ts | 8 +++++-- .../semantic-schema/schema-members.spec.ts | 23 +++++++++++++++++++ .../semantic-schema/schemas/type-union.ts | 10 +++++++- .../react/react-docs-from-schema.spec.ts | 17 ++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/components/entities/semantic-schema/api-schema.ts b/components/entities/semantic-schema/api-schema.ts index 104df8102756..b922f5d7034c 100644 --- a/components/entities/semantic-schema/api-schema.ts +++ b/components/entities/semantic-schema/api-schema.ts @@ -151,11 +151,15 @@ export class APISchema extends SchemaNode { /** * resolves a type reference to the declaration it points at. references to other components or to - * packages resolve to nothing: their declarations are not part of this schema. + * packages resolve to nothing: their declarations are not part of this schema. a reference to a + * declaration internal to a file only resolves within that file, so same-named declarations in other + * files are never mistaken for it. */ resolveRef(ref: TypeRefSchema): SchemaNode | undefined { if (!ref.isFromThisComponent()) return undefined; - return this.findDeclaration(ref.name); + const candidates = this.listDeclarations().filter((node) => node.name === ref.name); + if (!ref.internalFilePath) return candidates[0]; + return candidates.find((node) => node.location.filePath === ref.internalFilePath); } /** diff --git a/components/entities/semantic-schema/schema-members.spec.ts b/components/entities/semantic-schema/schema-members.spec.ts index 6f7c1312675c..76a8f8c461bb 100644 --- a/components/entities/semantic-schema/schema-members.spec.ts +++ b/components/entities/semantic-schema/schema-members.spec.ts @@ -15,6 +15,7 @@ import { TypeLiteralSchema, TypeRefSchema, TypeSchema, + TypeUnionSchema, VariableLikeSchema, } from './schemas'; @@ -57,6 +58,11 @@ describe('SchemaNode.getMembers()', () => { expect(names(intersection.getMembers())).to.deep.equal(['a', 'b']); }); + it('lists the members of every alternative of a union', () => { + const union = new TypeUnionSchema(loc, [literal(member('a'), member('shared')), literal(member('b'))]); + expect(names(union.getMembers())).to.deep.equal(['a', 'shared', 'b']); + }); + it('follows a type alias and parentheses to the underlying type', () => { const alias = new TypeSchema(loc, 'Props', new ParenthesizedTypeSchema(loc, literal(member('a'))), 'type Props'); expect(names(alias.getMembers())).to.deep.equal(['a']); @@ -154,6 +160,23 @@ describe('APISchema.getMembersOf()', () => { it('contributes nothing for an unknown reference', () => { expect(api([]).getMembersOf(new TypeRefSchema(loc, 'Missing'))).to.deep.equal([]); }); + + it('resolves a file-internal reference only within its file', () => { + const inButton: SchemaLocation = { filePath: 'button.tsx', line: 1, character: 1 }; + const inCard: SchemaLocation = { filePath: 'card.tsx', line: 1, character: 1 }; + const buttonProps = new TypeSchema(inButton, 'Props', literal(member('label')), 'type Props'); + const cardProps = new TypeSchema(inCard, 'Props', literal(member('title')), 'type Props'); + const schema = api( + [], + [], + [new ModuleSchema(inCard, [], [cardProps]), new ModuleSchema(inButton, [], [buttonProps])] + ); + + const toButton = new TypeRefSchema(loc, 'Props', undefined, undefined, 'button.tsx'); + const toElsewhere = new TypeRefSchema(loc, 'Props', undefined, undefined, 'missing.tsx'); + expect(names(schema.getMembersOf(toButton))).to.deep.equal(['label']); + expect(schema.getMembersOf(toElsewhere)).to.deep.equal([]); + }); }); describe('ParameterSchema.getBindingDefaults()', () => { diff --git a/components/entities/semantic-schema/schemas/type-union.ts b/components/entities/semantic-schema/schemas/type-union.ts index 83da744e257d..01dd0594d9d5 100644 --- a/components/entities/semantic-schema/schemas/type-union.ts +++ b/components/entities/semantic-schema/schemas/type-union.ts @@ -1,4 +1,4 @@ -import type { SchemaLocation } from '../schema-node'; +import type { GetMembersContext, SchemaLocation } from '../schema-node'; import { SchemaNode } from '../schema-node'; import { SchemaRegistry } from '../schema-registry'; @@ -11,6 +11,14 @@ export class TypeUnionSchema extends SchemaNode { super(); this.types = types; } + + /** + * a union of object types contributes the members of every alternative, in order. a member that only + * some alternatives declare is still listed, as declared by the first alternative that has it. + */ + getMembers(context: GetMembersContext = {}) { + return this.types.flatMap((type) => SchemaNode.membersOf(type, context)); + } toString(options?: { color?: boolean }) { return `${this.types.map((type) => type.toString(options)).join(' | ')}`; } diff --git a/scopes/react/react/react-docs-from-schema.spec.ts b/scopes/react/react/react-docs-from-schema.spec.ts index 54197ecee3ad..8d97ed933bcc 100644 --- a/scopes/react/react/react-docs-from-schema.spec.ts +++ b/scopes/react/react/react-docs-from-schema.spec.ts @@ -13,6 +13,7 @@ import { TypeLiteralSchema, TypeRefSchema, TypeSchema, + TypeUnionSchema, VariableLikeSchema, } from '@teambit/semantics.entities.semantic-schema'; import { ComponentID } from '@teambit/component-id'; @@ -188,6 +189,22 @@ describe('reactDocsFromSchema()', () => { expect(docs?.properties.map((prop) => prop.name)).to.deep.equal(['text', 'className']); }); + it('lists the props of every alternative of a union, each name once', () => { + // `type Props = { text?: string; variant?: string } | { icon?: string; variant?: string }` + const propsType = new TypeSchema( + loc, + 'ButtonProps', + new TypeUnionSchema(loc, [ + new TypeLiteralSchema(loc, [member('text', 'string', true), member('variant', 'string', true)]), + new TypeLiteralSchema(loc, [member('icon', 'string', true), member('variant', 'string', true)]), + ]), + 'type ButtonProps' + ); + const docs = reactDocsFromSchema(apiSchema([reactNode('Button', 'ButtonProps'), propsType])); + + expect(docs?.properties.map((prop) => prop.name)).to.deep.equal(['text', 'variant', 'icon']); + }); + it('exposes the component doc comment as the abstract', () => { const node = new ReactSchema( loc, From c51c71c2c4412d9f21dfee1808c5cffa2a803a6a Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Fri, 28 Aug 2026 10:21:57 -0400 Subject: [PATCH 4/6] fix(typescript, react): keep inline binding defaults; hydrate schemas from other package copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ParameterTransformer.getObjectBindingNodes()`: for `({ text = 'click' }: { text?: string })` the binding reuses the matching member of the inline props type, which knows nothing of the initializer — the default was lost. The member is now cloned with the binding's initializer as `defaultValue` (a field `VariableLikeSchema` has always serialized), so no wire-format change. - `reactDocsFromSchema()`: a schema built by another copy of the semantic-schema package (an env's extractor graph, an artifact hydrated elsewhere) has the serialized fields but not this package's members API. Such a schema is re-hydrated through `APISchema.fromObject(api.toObject())`, with the schema classes and `ReactSchema` registered in this copy's registry. The serialized form is the contract between versions; nothing walks node internals. Specs: extractor 3 (new), mapper 12 (+1), entity 20. Co-Authored-By: Claude Fable 5 --- .../react/react-docs-from-schema.spec.ts | 21 ++++++ scopes/react/react/react-docs-from-schema.ts | 23 ++++++- .../typescript/transformers/parameter.spec.ts | 67 +++++++++++++++++++ .../typescript/transformers/parameter.ts | 16 ++++- 4 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 scopes/typescript/typescript/transformers/parameter.spec.ts diff --git a/scopes/react/react/react-docs-from-schema.spec.ts b/scopes/react/react/react-docs-from-schema.spec.ts index 8d97ed933bcc..9d787b15d8bf 100644 --- a/scopes/react/react/react-docs-from-schema.spec.ts +++ b/scopes/react/react/react-docs-from-schema.spec.ts @@ -205,6 +205,27 @@ describe('reactDocsFromSchema()', () => { expect(docs?.properties.map((prop) => prop.name)).to.deep.equal(['text', 'variant', 'icon']); }); + it('describes a schema built by another copy of the semantic-schema package', () => { + // such a schema carries the serialized fields but not this package's prototype methods, `toObject()` aside — + // that is the contract between versions. + const propsType = new TypeSchema( + loc, + 'ButtonProps', + new TypeLiteralSchema(loc, [member('text', 'string', true, 'the button label')]), + 'type ButtonProps' + ); + const binding = new VariableLikeSchema(loc, 'text', 'text: string', new KeywordTypeSchema(loc, 'string'), true); + const api = apiSchema([reactNode('Button', 'ButtonProps', [binding]), propsType]); + const foreign = { ...api, toObject: () => api.toObject() } as unknown as APISchema; + expect((foreign as any).getMembersOf).to.be.undefined; + + expect(reactDocsFromSchema(foreign)).to.deep.equal(reactDocsFromSchema(api)); + expect(reactDocsFromSchema(foreign)?.properties[0]).to.deep.include({ + name: 'text', + description: 'the button label', + }); + }); + it('exposes the component doc comment as the abstract', () => { const node = new ReactSchema( loc, diff --git a/scopes/react/react/react-docs-from-schema.ts b/scopes/react/react/react-docs-from-schema.ts index feeb8ee97d0d..73176b4507a5 100644 --- a/scopes/react/react/react-docs-from-schema.ts +++ b/scopes/react/react/react-docs-from-schema.ts @@ -1,5 +1,5 @@ -import type { APISchema, SchemaNode } from '@teambit/semantics.entities.semantic-schema'; -import { VariableLikeSchema } from '@teambit/semantics.entities.semantic-schema'; +import type { SchemaNode } from '@teambit/semantics.entities.semantic-schema'; +import { APISchema, SchemaRegistry, Schemas, VariableLikeSchema } from '@teambit/semantics.entities.semantic-schema'; import { compact, uniqBy } from 'lodash'; import { ReactSchema } from './react.schema'; @@ -17,6 +17,22 @@ export type ReactDocsFromSchema = { properties: ReactDocsProperty[]; }; +let schemaClassesRegistered = false; + +/** + * a schema may have been built by another copy of the semantic-schema package — an env's extractor graph, + * or an artifact hydrated elsewhere — whose nodes predate the members API. the serialized form is the + * contract between versions, so such a schema is re-hydrated through this package's classes. + */ +function normalize(api: APISchema): APISchema { + if (typeof api.getMembersOf === 'function') return api; + if (!schemaClassesRegistered) { + SchemaRegistry.registerGetSchemas(() => [...Object.values(Schemas), ReactSchema]); + schemaClassesRegistered = true; + } + return APISchema.fromObject(api.toObject()); +} + function toProperty(member: SchemaNode, defaults: Map): ReactDocsProperty | undefined { if (!member.name) return undefined; const defaultValue = defaults.get(member.name); @@ -39,7 +55,8 @@ function toProperty(member: SchemaNode, defaults: Map): ReactDoc * only the first component that resolves any props is described, which is what the docs UI has always * rendered — it reads a single entry, not one per export. */ -export function reactDocsFromSchema(api: APISchema): ReactDocsFromSchema | undefined { +export function reactDocsFromSchema(schema: APISchema): ReactDocsFromSchema | undefined { + const api = normalize(schema); const reactNodes = api.module.listExports().filter(ReactSchema.isReactSchema); if (!reactNodes.length) return undefined; diff --git a/scopes/typescript/typescript/transformers/parameter.spec.ts b/scopes/typescript/typescript/transformers/parameter.spec.ts new file mode 100644 index 000000000000..ef74f84667ef --- /dev/null +++ b/scopes/typescript/typescript/transformers/parameter.spec.ts @@ -0,0 +1,67 @@ +import { expect } from 'chai'; +import ts from 'typescript'; +import type { ParameterDeclaration } from 'typescript'; +import type { Location } from '@teambit/semantics.entities.semantic-schema'; +import { KeywordTypeSchema, TypeLiteralSchema, VariableLikeSchema } from '@teambit/semantics.entities.semantic-schema'; +import type { SchemaExtractorContext } from '../schema-extractor-context'; +import { ParameterTransformer } from './parameter'; + +const loc: Location = { filePath: 'button.tsx', line: 1, character: 1 }; + +function firstParameter(source: string): ParameterDeclaration { + const file = ts.createSourceFile('button.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + let param: ParameterDeclaration | undefined; + const visit = (node: ts.Node) => { + if (!param && ts.isParameter(node)) param = node; + ts.forEachChild(node, visit); + }; + visit(file); + if (!param) throw new Error('no parameter in source'); + return param; +} + +function member(name: string, defaultValue?: string) { + return new VariableLikeSchema( + loc, + name, + `${name}?: string`, + new KeywordTypeSchema(loc, 'string'), + true, + undefined, + defaultValue + ); +} + +// the paths under test resolve the binding from the already-extracted props type, so no tsserver is needed. +const context = {} as SchemaExtractorContext; + +describe('ParameterTransformer.getObjectBindingNodes()', () => { + it('carries the binding initializer onto the member of an inline props type', async () => { + const param = firstParameter(`function Button({ text = 'click' }: { text?: string }) {}`); + const propsType = new TypeLiteralSchema(loc, [member('text')]); + + const [binding] = (await ParameterTransformer.getObjectBindingNodes(param, propsType, context)) || []; + + expect(VariableLikeSchema.isVariableLikeSchema(binding)).to.equal(true); + expect(binding.name).to.equal('text'); + expect((binding as VariableLikeSchema).defaultValue).to.equal(`'click'`); + expect((binding as VariableLikeSchema).type.toString()).to.equal('string'); + }); + + it('returns the member itself when the binding has no initializer', async () => { + const param = firstParameter(`function Button({ text }: { text?: string }) {}`); + const text = member('text'); + const propsType = new TypeLiteralSchema(loc, [text]); + + const [binding] = (await ParameterTransformer.getObjectBindingNodes(param, propsType, context)) || []; + + expect(binding).to.equal(text); + }); + + it('does not describe a parameter that is not an object binding pattern', async () => { + const param = firstParameter(`function Button(props: { text?: string }) {}`); + + expect(await ParameterTransformer.getObjectBindingNodes(param, new TypeLiteralSchema(loc, []), context)).to.be + .undefined; + }); +}); diff --git a/scopes/typescript/typescript/transformers/parameter.ts b/scopes/typescript/typescript/transformers/parameter.ts index dad8f0215c96..2ce7ce4ecaac 100644 --- a/scopes/typescript/typescript/transformers/parameter.ts +++ b/scopes/typescript/typescript/transformers/parameter.ts @@ -6,6 +6,7 @@ import { ParameterSchema, TupleTypeSchema, TypeLiteralSchema, + VariableLikeSchema, } from '@teambit/semantics.entities.semantic-schema'; import pMapSeries from 'p-map-series'; import type { SchemaTransformer } from '../schema-transformer'; @@ -90,12 +91,25 @@ export class ParameterTransformer implements SchemaTransformer { const existing = paramType.findNode?.((node) => { return node.name === elem.name.getText().trim(); }); + const defaultValue = elem.initializer ? elem.initializer.getText() : undefined; if (existing && existing.__schema !== 'InferenceTypeSchema') { + // the member of an inline props type describes the binding better than quick-info would, but it + // knows nothing of the binding's initializer — so carry that over, or the default is lost. + if (defaultValue !== undefined && VariableLikeSchema.isVariableLikeSchema(existing)) { + return new VariableLikeSchema( + existing.location, + existing.name, + existing.signature, + existing.type, + existing.isOptional, + existing.doc, + defaultValue + ); + } return existing; } const info = await context.getQuickInfo(elem.name); const parsed = info ? parseTypeFromQuickInfo(info) : elem.getText(); - const defaultValue = elem.initializer ? elem.initializer.getText() : undefined; const alias = elem.propertyName && isComputedPropertyName(elem.propertyName) ? elem.propertyName?.expression.getText() From 8b96fce73314165b70a38ae0248ec856e520d262 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Fri, 28 Aug 2026 10:32:54 -0400 Subject: [PATCH 5/6] feat(react, semantic-schema): class components, exported aliases/references, union requiredness - `ReactAPITransformer` also recognises classes extending React's `Component` / `PureComponent` (namespaced or not) and takes their props from the base class's type argument; `.js` files count as React files alongside `.tsx`/`.jsx`. - `ModuleSchema.findExport()` + `APISchema.findDeclaration()` resolve the name a declaration is exported under (`export { Props as ButtonProps }`); `APISchema.listExportedDeclarations()` follows an exported reference to its local declaration (`export default Button`). The mapper uses the latter. - `TypeUnionSchema.getMembers()` computes requiredness across alternatives: a member some alternative lacks or leaves optional is contributed as optional. - `SchemaNode.membersOf()` tracks the current path rather than the whole traversal, so a base type shared by two branches contributes to each. - `getObjectBindingNodes()` keys `{ text: label = 'x' }` by the prop `text`. Specs: entity 24 (+4), mapper 13 (+1), extractor 4 (+1), transformer 3 (new). Co-Authored-By: Claude Fable 5 --- .../entities/semantic-schema/api-schema.ts | 35 ++++-- .../semantic-schema/schema-members.spec.ts | 38 +++++++ .../entities/semantic-schema/schema-node.ts | 8 +- .../semantic-schema/schemas/module.ts | 15 +++ .../semantic-schema/schemas/type-union.ts | 28 ++++- .../react/react-docs-from-schema.spec.ts | 25 ++++ scopes/react/react/react-docs-from-schema.ts | 2 +- .../react/react/react.api.transformer.spec.ts | 51 +++++++++ scopes/react/react/react.api.transformer.ts | 107 +++++++++++++----- .../typescript/transformers/parameter.spec.ts | 10 ++ .../typescript/transformers/parameter.ts | 5 +- 11 files changed, 282 insertions(+), 42 deletions(-) create mode 100644 scopes/react/react/react.api.transformer.spec.ts diff --git a/components/entities/semantic-schema/api-schema.ts b/components/entities/semantic-schema/api-schema.ts index b922f5d7034c..3b624214255f 100644 --- a/components/entities/semantic-schema/api-schema.ts +++ b/components/entities/semantic-schema/api-schema.ts @@ -1,7 +1,6 @@ import chalk from 'chalk'; import { ComponentID } from '@teambit/component-id'; -import { ExportSchema, ModuleSchema } from './schemas'; -import type { TypeRefSchema } from './schemas'; +import { ExportSchema, ModuleSchema, TypeRefSchema } from './schemas'; import type { SchemaLocation } from './schema-node'; import { SchemaNode } from './schema-node'; import { TagName } from './schemas/docs/tag'; @@ -143,10 +142,24 @@ export class APISchema extends SchemaNode { } /** - * finds a declaration of this component by name, exported or internal. + * the declarations the component exports, following an exported reference to the local declaration + * it points at — `export default Button` exports a reference to `Button`. */ - findDeclaration(name: string): SchemaNode | undefined { - return this.listDeclarations().find((node) => node.name === name); + listExportedDeclarations(): SchemaNode[] { + return this.module.listExports().map((node) => { + return (TypeRefSchema.isTypeRefSchema(node) && this.resolveRef(node)) || node; + }); + } + + /** + * finds a declaration of this component by name. with `filePath`, only a declaration internal to that + * file matches. without it, the name may also be one the component exports the declaration under + * (`export { Props as ButtonProps }`). + */ + findDeclaration(name: string, filePath?: string): SchemaNode | undefined { + const candidates = this.listDeclarations().filter((node) => node.name === name); + if (filePath) return candidates.find((node) => node.location.filePath === filePath); + return candidates[0] || this.findExportedAs(name); } /** @@ -157,9 +170,15 @@ export class APISchema extends SchemaNode { */ resolveRef(ref: TypeRefSchema): SchemaNode | undefined { if (!ref.isFromThisComponent()) return undefined; - const candidates = this.listDeclarations().filter((node) => node.name === ref.name); - if (!ref.internalFilePath) return candidates[0]; - return candidates.find((node) => node.location.filePath === ref.internalFilePath); + return this.findDeclaration(ref.name, ref.internalFilePath); + } + + private findExportedAs(name: string): SchemaNode | undefined { + for (const module of [this.module, ...this.internals]) { + const found = module.findExport(name); + if (found) return found; + } + return undefined; } /** diff --git a/components/entities/semantic-schema/schema-members.spec.ts b/components/entities/semantic-schema/schema-members.spec.ts index 76a8f8c461bb..209e50f0f502 100644 --- a/components/entities/semantic-schema/schema-members.spec.ts +++ b/components/entities/semantic-schema/schema-members.spec.ts @@ -63,6 +63,27 @@ describe('SchemaNode.getMembers()', () => { expect(names(union.getMembers())).to.deep.equal(['a', 'shared', 'b']); }); + it('only keeps a union member required when every alternative requires it', () => { + // `{ id: string; a: string } | { id: string; a?: string; b: string }` + const union = new TypeUnionSchema(loc, [ + literal(member('id', 'string', false), member('a', 'string', false)), + literal(member('id', 'string', false), member('a', 'string', true), member('b', 'string', false)), + ]); + const required = union.getMembers().map((m) => `${m.name}${(m as VariableLikeSchema).isOptional ? '?' : ''}`); + expect(required).to.deep.equal(['id', 'a?', 'id', 'a?', 'b?']); + }); + + it('lets a type shared by two branches contribute to each', () => { + const base = iface('Base', [member('id', 'string', false)]); + const shared = new TypeRefSchema(loc, 'Base'); + const union = new TypeUnionSchema(loc, [ + new TypeIntersectionSchema(loc, [shared, literal(member('a'))]), + new TypeIntersectionSchema(loc, [shared, literal(member('b'))]), + ]); + const resolveRef = () => base; + expect(names(union.getMembers({ resolveRef }))).to.deep.equal(['id', 'a', 'id', 'b']); + }); + it('follows a type alias and parentheses to the underlying type', () => { const alias = new TypeSchema(loc, 'Props', new ParenthesizedTypeSchema(loc, literal(member('a'))), 'type Props'); expect(names(alias.getMembers())).to.deep.equal(['a']); @@ -161,6 +182,23 @@ describe('APISchema.getMembersOf()', () => { expect(api([]).getMembersOf(new TypeRefSchema(loc, 'Missing'))).to.deep.equal([]); }); + it('resolves the name a declaration is exported under', () => { + // `export { Props as ButtonProps }` + const schema = api([new ExportSchema(loc, 'Props', iface('Props', [member('a')]), 'ButtonProps')]); + expect(names(schema.getMembersOf(new TypeRefSchema(loc, 'ButtonProps')))).to.deep.equal(['a']); + }); + + it('follows an exported reference to the local declaration it points at', () => { + // `const Button = ...; export default Button` — the export is a reference, the declaration is internal. + const inButton: SchemaLocation = { filePath: 'button.tsx', line: 1, character: 1 }; + const button = new TypeSchema(inButton, 'Button', literal(member('a')), 'type Button'); + const schema = api( + [new ExportSchema(loc, 'default', new TypeRefSchema(loc, 'Button', undefined, undefined, 'button.tsx'))], + [button] + ); + expect(schema.listExportedDeclarations()).to.deep.equal([button]); + }); + it('resolves a file-internal reference only within its file', () => { const inButton: SchemaLocation = { filePath: 'button.tsx', line: 1, character: 1 }; const inCard: SchemaLocation = { filePath: 'card.tsx', line: 1, character: 1 }; diff --git a/components/entities/semantic-schema/schema-node.ts b/components/entities/semantic-schema/schema-node.ts index 631c1dd5c0bd..0e0255b32ded 100644 --- a/components/entities/semantic-schema/schema-node.ts +++ b/components/entities/semantic-schema/schema-node.ts @@ -14,7 +14,7 @@ export type GetMembersContext = { */ resolveRef?: (ref: TypeRefSchema) => SchemaNode | undefined; /** - * nodes already being expanded in this traversal. guards against self-referencing types. + * the nodes being expanded on the current path. guards against self-referencing types. */ visited?: Set; }; @@ -115,11 +115,13 @@ export abstract class SchemaNode implements ISchemaNode { } /** - * the members of a child node, expanded at most once per traversal so self-referencing types terminate. + * the members of a child node. a node already being expanded on the current path contributes nothing, + * so self-referencing types terminate — while a type shared by two branches (say, a base of both + * alternatives of a union) still contributes to each. */ protected static membersOf(node: SchemaNode | undefined, context: GetMembersContext): SchemaNode[] { if (!node) return []; - const visited = context.visited || new Set(); + const visited = new Set(context.visited); if (visited.has(node)) return []; visited.add(node); return node.getMembers({ ...context, visited }); diff --git a/components/entities/semantic-schema/schemas/module.ts b/components/entities/semantic-schema/schemas/module.ts index 1e60d5e5cf61..c811f7caa7f8 100644 --- a/components/entities/semantic-schema/schemas/module.ts +++ b/components/entities/semantic-schema/schemas/module.ts @@ -39,6 +39,21 @@ export class ModuleSchema extends SchemaNode { return [...this.listExports(), ...this.internals.flatMap((node) => ModuleSchema.unwrap(node))]; } + /** + * the declaration this module exports under `name` — its own name, or an alias such as + * `export { Props as ButtonProps }` or `export default Button`. + */ + findExport(name: string): SchemaNode | undefined { + for (const node of this.exports) { + if (ExportSchema.isExportSchema(node)) { + if ((node.alias || node.name) === name) return ModuleSchema.unwrap(node)[0]; + } else if (node.name === name) { + return node; + } + } + return undefined; + } + private static unwrap(node: SchemaNode): SchemaNode[] { if (ExportSchema.isExportSchema(node)) return ModuleSchema.unwrap(node.exportNode); if (ModuleSchema.isModuleSchema(node)) return node.listExports(); diff --git a/components/entities/semantic-schema/schemas/type-union.ts b/components/entities/semantic-schema/schemas/type-union.ts index 01dd0594d9d5..25ec88633980 100644 --- a/components/entities/semantic-schema/schemas/type-union.ts +++ b/components/entities/semantic-schema/schemas/type-union.ts @@ -1,6 +1,7 @@ import type { GetMembersContext, SchemaLocation } from '../schema-node'; import { SchemaNode } from '../schema-node'; import { SchemaRegistry } from '../schema-registry'; +import { VariableLikeSchema } from './variable-like'; export class TypeUnionSchema extends SchemaNode { readonly types: SchemaNode[]; @@ -13,11 +14,32 @@ export class TypeUnionSchema extends SchemaNode { } /** - * a union of object types contributes the members of every alternative, in order. a member that only - * some alternatives declare is still listed, as declared by the first alternative that has it. + * a union of object types contributes the members of every alternative, in order. a value of the union + * only surely has a member every alternative requires, so a member some alternative lacks or leaves + * optional is contributed as optional. */ getMembers(context: GetMembersContext = {}) { - return this.types.flatMap((type) => SchemaNode.membersOf(type, context)); + const perAlternative = this.types.map((type) => SchemaNode.membersOf(type, context)); + const requiredEverywhere = (name: string) => + perAlternative.every((members) => + members.some( + (member) => member.name === name && VariableLikeSchema.isVariableLikeSchema(member) && !member.isOptional + ) + ); + + return perAlternative.flat().map((member) => { + if (!VariableLikeSchema.isVariableLikeSchema(member) || member.isOptional || !member.name) return member; + if (requiredEverywhere(member.name)) return member; + return new VariableLikeSchema( + member.location, + member.name, + member.signature, + member.type, + true, + member.doc, + member.defaultValue + ); + }); } toString(options?: { color?: boolean }) { return `${this.types.map((type) => type.toString(options)).join(' | ')}`; diff --git a/scopes/react/react/react-docs-from-schema.spec.ts b/scopes/react/react/react-docs-from-schema.spec.ts index 9d787b15d8bf..56167ad73d10 100644 --- a/scopes/react/react/react-docs-from-schema.spec.ts +++ b/scopes/react/react/react-docs-from-schema.spec.ts @@ -205,6 +205,31 @@ describe('reactDocsFromSchema()', () => { expect(docs?.properties.map((prop) => prop.name)).to.deep.equal(['text', 'variant', 'icon']); }); + it('describes a component exported by reference, as `export default Button` is', () => { + const inButton: SchemaLocation = { filePath: 'button.tsx', line: 1, character: 1 }; + const propsType = new TypeSchema( + loc, + 'ButtonProps', + new TypeLiteralSchema(loc, [member('text', 'string', true)]), + 'type ButtonProps' + ); + const button = new ReactSchema( + inButton, + 'Button', + new TypeRefSchema(loc, 'JSX.Element'), + new ParameterSchema(loc, 'props', new TypeRefSchema(loc, 'ButtonProps'), false) + ); + const api = apiSchema( + [ + new ExportSchema(loc, 'default', new TypeRefSchema(loc, 'Button', undefined, undefined, 'button.tsx')), + propsType, + ], + [button] + ); + + expect(reactDocsFromSchema(api)?.properties.map((prop) => prop.name)).to.deep.equal(['text']); + }); + it('describes a schema built by another copy of the semantic-schema package', () => { // such a schema carries the serialized fields but not this package's prototype methods, `toObject()` aside — // that is the contract between versions. diff --git a/scopes/react/react/react-docs-from-schema.ts b/scopes/react/react/react-docs-from-schema.ts index 73176b4507a5..5882cf3385af 100644 --- a/scopes/react/react/react-docs-from-schema.ts +++ b/scopes/react/react/react-docs-from-schema.ts @@ -57,7 +57,7 @@ function toProperty(member: SchemaNode, defaults: Map): ReactDoc */ export function reactDocsFromSchema(schema: APISchema): ReactDocsFromSchema | undefined { const api = normalize(schema); - const reactNodes = api.module.listExports().filter(ReactSchema.isReactSchema); + const reactNodes = api.listExportedDeclarations().filter(ReactSchema.isReactSchema); if (!reactNodes.length) return undefined; const docsFor = (node: ReactSchema): ReactDocsFromSchema => { diff --git a/scopes/react/react/react.api.transformer.spec.ts b/scopes/react/react/react.api.transformer.spec.ts new file mode 100644 index 000000000000..f0fb9e5490a4 --- /dev/null +++ b/scopes/react/react/react.api.transformer.spec.ts @@ -0,0 +1,51 @@ +import { expect } from 'chai'; +import type { Location as SchemaLocation } from '@teambit/semantics.entities.semantic-schema'; +import { + ClassSchema, + ExpressionWithTypeArgumentsSchema, + TypeRefSchema, +} from '@teambit/semantics.entities.semantic-schema'; +import { ReactSchema } from './react.schema'; +import { ReactAPITransformer } from './react.api.transformer'; + +const inTsx: SchemaLocation = { filePath: 'button.tsx', line: 1, character: 1 }; +const inTs: SchemaLocation = { filePath: 'store.ts', line: 1, character: 1 }; +const inJs: SchemaLocation = { filePath: 'hero-button.js', line: 1, character: 1 }; + +function classExtending(base: string, propsType: string | undefined, location: SchemaLocation) { + const typeArgs = propsType ? [new TypeRefSchema(location, propsType)] : []; + const extendsNode = new ExpressionWithTypeArgumentsSchema( + typeArgs, + new TypeRefSchema(location, base, undefined, 'react'), + base, + location + ); + return new ClassSchema('Button', [], location, `class Button extends ${base}`, undefined, undefined, [extendsNode]); +} + +describe('ReactAPITransformer', () => { + const transformer = new ReactAPITransformer(); + + it('recognises a class extending React.Component and takes its props from the type argument', async () => { + const node = classExtending('React.Component', 'ButtonProps', inTsx); + + expect(transformer.predicate(node)).to.equal(true); + const react = (await transformer.transform(node)) as ReactSchema; + expect(ReactSchema.isReactSchema(react)).to.equal(true); + expect(react.name).to.equal('Button'); + expect(react.props?.type.name).to.equal('ButtonProps'); + }); + + it('recognises PureComponent and an un-namespaced Component, in a .js file too', async () => { + expect(transformer.predicate(classExtending('PureComponent', 'Props', inTsx))).to.equal(true); + expect(transformer.predicate(classExtending('Component', undefined, inJs))).to.equal(true); + const react = (await transformer.transform(classExtending('Component', undefined, inJs))) as ReactSchema; + expect(react.props).to.be.undefined; + }); + + it('leaves other classes alone, including React-looking ones outside React files', () => { + expect(transformer.predicate(classExtending('EventEmitter', undefined, inTsx))).to.equal(false); + expect(transformer.predicate(classExtending('React.Component', 'Props', inTs))).to.equal(false); + expect(transformer.predicate(new ClassSchema('Store', [], inTsx, 'class Store'))).to.equal(false); + }); +}); diff --git a/scopes/react/react/react.api.transformer.ts b/scopes/react/react/react.api.transformer.ts index 54123eee748e..efee9bcb4ca9 100644 --- a/scopes/react/react/react.api.transformer.ts +++ b/scopes/react/react/react.api.transformer.ts @@ -1,35 +1,58 @@ -import type { ParameterSchema, SchemaNode } from '@teambit/semantics.entities.semantic-schema'; -import { FunctionLikeSchema, TypeRefSchema } from '@teambit/semantics.entities.semantic-schema'; +import type { + ExpressionWithTypeArgumentsSchema, + ParameterSchema, + SchemaNode, +} from '@teambit/semantics.entities.semantic-schema'; +import { + ClassSchema, + FunctionLikeSchema, + ParameterSchema as Parameter, + TypeRefSchema, +} from '@teambit/semantics.entities.semantic-schema'; import type { SchemaNodeTransformer } from '@teambit/typescript'; import { ReactSchema } from './react.schema'; -const REACT_FILE_EXT = ['.tsx', '.jsx']; +const REACT_FILE_EXT = ['.tsx', '.jsx', '.js']; -// only detects functional react components for now +const REACT_ELEMENT_TYPES = [ + 'JSX.Element', + 'React.ReactNode', + 'null', + 'undefined', + 'React.ReactChild', + 'React.ReactFragment', + 'React.ReactPortal', + 'React.JSX.Element', +]; + +/** + * `React.Component`, `Component`, `PureComponent` — with or without the namespace. + */ +const REACT_BASE_CLASS = /(^|\.)(Pure)?Component$/; + +/** + * turns the declarations that describe a React component into a `ReactSchema`: a function returning an + * element, or a class extending React's component base classes. + */ export class ReactAPITransformer implements SchemaNodeTransformer { predicate(node: SchemaNode) { - const isFunctionLike = node.__schema === FunctionLikeSchema.name; - if (!isFunctionLike) return false; - const functionNode = node as FunctionLikeSchema; - const isReactFile = REACT_FILE_EXT.some((r) => functionNode.location.filePath.includes(r)); - if (!isReactFile) return false; - const params = functionNode.params; - if (params.length > 1) return false; - const returnsPotentialReactElement = [ - 'JSX.Element', - 'React.ReactNode', - 'null', - 'undefined', - 'React.ReactChild', - 'React.ReactFragment', - 'React.ReactPortal', - 'React.JSX.Element', - ].includes(this.getReturnTypeName(node as FunctionLikeSchema)); - if (!returnsPotentialReactElement) return false; - return true; - } - - async transform(node: FunctionLikeSchema): Promise { + if (node.__schema === FunctionLikeSchema.name) return this.isFunctionComponent(node as FunctionLikeSchema); + if (node.__schema === ClassSchema.name) return Boolean(this.reactBaseOf(node as ClassSchema)); + return false; + } + + async transform(node: FunctionLikeSchema | ClassSchema): Promise { + if (node.__schema === ClassSchema.name) return this.transformClass(node as ClassSchema); + return this.transformFunction(node as FunctionLikeSchema); + } + + private isFunctionComponent(node: FunctionLikeSchema) { + if (!this.isReactFile(node)) return false; + if (node.params.length > 1) return false; + return REACT_ELEMENT_TYPES.includes(this.getReturnTypeName(node)); + } + + private transformFunction(node: FunctionLikeSchema): ReactSchema { return new ReactSchema( node.location, node.name, @@ -42,6 +65,38 @@ export class ReactAPITransformer implements SchemaNodeTransformer { ); } + /** + * the props of a class component are the first type argument of its React base class: + * `class Button extends React.Component`. + */ + private transformClass(node: ClassSchema): ReactSchema { + const base = this.reactBaseOf(node); + const propsType = base?.typeArgs[0]; + const props = propsType + ? (new Parameter(propsType.location, 'props', propsType, false) as ParameterSchema) + : undefined; + + return new ReactSchema( + node.location, + node.name, + new TypeRefSchema(node.location, 'React.ReactNode', undefined, 'react'), + props, + node.signature, + [], + node.doc, + node.typeParams + ); + } + + private reactBaseOf(node: ClassSchema): ExpressionWithTypeArgumentsSchema | undefined { + if (!this.isReactFile(node)) return undefined; + return node.extendsNodes?.find((base) => REACT_BASE_CLASS.test(base.name)); + } + + private isReactFile(node: SchemaNode) { + return REACT_FILE_EXT.some((ext) => node.location.filePath.endsWith(ext)); + } + private getReturnTypeName(node: FunctionLikeSchema): string { const returnType = node.returnType; return returnType.name ?? returnType.toString(); diff --git a/scopes/typescript/typescript/transformers/parameter.spec.ts b/scopes/typescript/typescript/transformers/parameter.spec.ts index ef74f84667ef..6649384b7764 100644 --- a/scopes/typescript/typescript/transformers/parameter.spec.ts +++ b/scopes/typescript/typescript/transformers/parameter.spec.ts @@ -48,6 +48,16 @@ describe('ParameterTransformer.getObjectBindingNodes()', () => { expect((binding as VariableLikeSchema).type.toString()).to.equal('string'); }); + it('keys an aliased binding by the prop it destructures, not the local name', async () => { + const param = firstParameter(`function Button({ text: label = 'click' }: { text?: string }) {}`); + const propsType = new TypeLiteralSchema(loc, [member('text')]); + + const [binding] = (await ParameterTransformer.getObjectBindingNodes(param, propsType, context)) || []; + + expect(binding.name).to.equal('text'); + expect((binding as VariableLikeSchema).defaultValue).to.equal(`'click'`); + }); + it('returns the member itself when the binding has no initializer', async () => { const param = firstParameter(`function Button({ text }: { text?: string }) {}`); const text = member('text'); diff --git a/scopes/typescript/typescript/transformers/parameter.ts b/scopes/typescript/typescript/transformers/parameter.ts index 2ce7ce4ecaac..9e5920d64234 100644 --- a/scopes/typescript/typescript/transformers/parameter.ts +++ b/scopes/typescript/typescript/transformers/parameter.ts @@ -88,8 +88,11 @@ export class ParameterTransformer implements SchemaTransformer { ): Promise { if (param.name.kind !== SyntaxKind.ObjectBindingPattern) return undefined; return pMapSeries(param.name.elements, async (elem: BindingElement) => { + // `{ text: label = 'x' }` binds the prop `text` to the local `label`; the prop is what the props type declares. + const propertyName = + elem.propertyName && isIdentifier(elem.propertyName) ? elem.propertyName.getText() : elem.name.getText().trim(); const existing = paramType.findNode?.((node) => { - return node.name === elem.name.getText().trim(); + return node.name === propertyName; }); const defaultValue = elem.initializer ? elem.initializer.getText() : undefined; if (existing && existing.__schema !== 'InferenceTypeSchema') { From 71570779b3261b58254d1dd50e0182803be37a6c Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Fri, 28 Aug 2026 10:39:54 -0400 Subject: [PATCH 6/6] fix(semantic-schema, react): union members merge their types; class base must be React's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `TypeUnionSchema.getMembers()` contributes one member per name: the types the alternatives give it are unioned (`{ value: string } | { value: number }` → `value: string | number`), requiredness stays "required everywhere". - `ReactAPITransformer` accepts a `Component`/`PureComponent` base only when it resolved to the `react` package or did not resolve at all — a base from another package, another component or a file of this one is not React's. Co-Authored-By: Claude Fable 5 --- .../semantic-schema/schema-members.spec.ts | 17 ++++++- .../semantic-schema/schemas/type-union.ts | 48 ++++++++++++------- .../react/react/react.api.transformer.spec.ts | 27 ++++++++--- scopes/react/react/react.api.transformer.ts | 12 ++++- 4 files changed, 76 insertions(+), 28 deletions(-) diff --git a/components/entities/semantic-schema/schema-members.spec.ts b/components/entities/semantic-schema/schema-members.spec.ts index 209e50f0f502..0403f2905755 100644 --- a/components/entities/semantic-schema/schema-members.spec.ts +++ b/components/entities/semantic-schema/schema-members.spec.ts @@ -70,7 +70,18 @@ describe('SchemaNode.getMembers()', () => { literal(member('id', 'string', false), member('a', 'string', true), member('b', 'string', false)), ]); const required = union.getMembers().map((m) => `${m.name}${(m as VariableLikeSchema).isOptional ? '?' : ''}`); - expect(required).to.deep.equal(['id', 'a?', 'id', 'a?', 'b?']); + expect(required).to.deep.equal(['id', 'a?', 'b?']); + }); + + it('unions the types the alternatives give one member', () => { + // `{ value: string } | { value: number }` + const union = new TypeUnionSchema(loc, [ + literal(member('value', 'string', false)), + literal(member('value', 'number', false)), + ]); + const [value] = union.getMembers() as VariableLikeSchema[]; + expect(value.type.toString()).to.equal('string | number'); + expect(value.isOptional).to.equal(false); }); it('lets a type shared by two branches contribute to each', () => { @@ -81,7 +92,9 @@ describe('SchemaNode.getMembers()', () => { new TypeIntersectionSchema(loc, [shared, literal(member('b'))]), ]); const resolveRef = () => base; - expect(names(union.getMembers({ resolveRef }))).to.deep.equal(['id', 'a', 'id', 'b']); + const members = union.getMembers({ resolveRef }) as VariableLikeSchema[]; + expect(names(members)).to.deep.equal(['id', 'a', 'b']); + expect(members[0].isOptional).to.equal(false); }); it('follows a type alias and parentheses to the underlying type', () => { diff --git a/components/entities/semantic-schema/schemas/type-union.ts b/components/entities/semantic-schema/schemas/type-union.ts index 25ec88633980..3bf7d70b6e11 100644 --- a/components/entities/semantic-schema/schemas/type-union.ts +++ b/components/entities/semantic-schema/schemas/type-union.ts @@ -1,3 +1,4 @@ +import { uniqBy } from 'lodash'; import type { GetMembersContext, SchemaLocation } from '../schema-node'; import { SchemaNode } from '../schema-node'; import { SchemaRegistry } from '../schema-registry'; @@ -14,32 +15,43 @@ export class TypeUnionSchema extends SchemaNode { } /** - * a union of object types contributes the members of every alternative, in order. a value of the union - * only surely has a member every alternative requires, so a member some alternative lacks or leaves - * optional is contributed as optional. + * a union of object types contributes one member per name, in first-seen order. a member the + * alternatives type differently gets the union of those types (`{ value: string } | { value: number }` + * contributes `value: string | number`). a value of the union only surely has a member every alternative + * requires, so a member some alternative lacks or leaves optional is contributed as optional. */ getMembers(context: GetMembersContext = {}) { const perAlternative = this.types.map((type) => SchemaNode.membersOf(type, context)); - const requiredEverywhere = (name: string) => - perAlternative.every((members) => - members.some( - (member) => member.name === name && VariableLikeSchema.isVariableLikeSchema(member) && !member.isOptional - ) + const variables = perAlternative.flat().filter(VariableLikeSchema.isVariableLikeSchema); + const others = perAlternative.flat().filter((member) => !VariableLikeSchema.isVariableLikeSchema(member)); + + const byName = new Map(); + variables.forEach((member) => byName.set(member.name, [...(byName.get(member.name) || []), member])); + + const merged = [...byName.entries()].map(([name, declarations]) => { + const [first] = declarations; + const requiredEverywhere = perAlternative.every((members) => + members.some((m) => m.name === name && VariableLikeSchema.isVariableLikeSchema(m) && !m.isOptional) ); + const types = uniqBy( + declarations.map((declaration) => declaration.type), + (type) => type.toString() + ); + const type = types.length === 1 ? first.type : new TypeUnionSchema(first.location, types); + if (type === first.type && requiredEverywhere === !first.isOptional) return first; - return perAlternative.flat().map((member) => { - if (!VariableLikeSchema.isVariableLikeSchema(member) || member.isOptional || !member.name) return member; - if (requiredEverywhere(member.name)) return member; return new VariableLikeSchema( - member.location, - member.name, - member.signature, - member.type, - true, - member.doc, - member.defaultValue + first.location, + name, + first.signature, + type, + !requiredEverywhere, + declarations.find((declaration) => declaration.doc)?.doc, + declarations.find((declaration) => declaration.defaultValue !== undefined)?.defaultValue ); }); + + return [...merged, ...others]; } toString(options?: { color?: boolean }) { return `${this.types.map((type) => type.toString(options)).join(' | ')}`; diff --git a/scopes/react/react/react.api.transformer.spec.ts b/scopes/react/react/react.api.transformer.spec.ts index f0fb9e5490a4..0ce16e3153fe 100644 --- a/scopes/react/react/react.api.transformer.spec.ts +++ b/scopes/react/react/react.api.transformer.spec.ts @@ -5,6 +5,7 @@ import { ExpressionWithTypeArgumentsSchema, TypeRefSchema, } from '@teambit/semantics.entities.semantic-schema'; +import { ComponentID } from '@teambit/component-id'; import { ReactSchema } from './react.schema'; import { ReactAPITransformer } from './react.api.transformer'; @@ -12,14 +13,14 @@ const inTsx: SchemaLocation = { filePath: 'button.tsx', line: 1, character: 1 }; const inTs: SchemaLocation = { filePath: 'store.ts', line: 1, character: 1 }; const inJs: SchemaLocation = { filePath: 'hero-button.js', line: 1, character: 1 }; -function classExtending(base: string, propsType: string | undefined, location: SchemaLocation) { +function classExtending( + base: string, + propsType: string | undefined, + location: SchemaLocation, + baseRef: TypeRefSchema = new TypeRefSchema(location, base, undefined, 'react') +) { const typeArgs = propsType ? [new TypeRefSchema(location, propsType)] : []; - const extendsNode = new ExpressionWithTypeArgumentsSchema( - typeArgs, - new TypeRefSchema(location, base, undefined, 'react'), - base, - location - ); + const extendsNode = new ExpressionWithTypeArgumentsSchema(typeArgs, baseRef, base, location); return new ClassSchema('Button', [], location, `class Button extends ${base}`, undefined, undefined, [extendsNode]); } @@ -48,4 +49,16 @@ describe('ReactAPITransformer', () => { expect(transformer.predicate(classExtending('React.Component', 'Props', inTs))).to.equal(false); expect(transformer.predicate(new ClassSchema('Store', [], inTsx, 'class Store'))).to.equal(false); }); + + it("only accepts a Component base that is React's, or one the extractor could not resolve", () => { + const fromOtherPackage = new TypeRefSchema(inTsx, 'Some.Component', undefined, 'some'); + const fromOtherComponent = new TypeRefSchema(inTsx, 'Component', ComponentID.fromString('org.scope/base')); + const fromThisFile = new TypeRefSchema(inTsx, 'Component', undefined, undefined, 'component.tsx'); + const unresolved = new TypeRefSchema(inTsx, 'React.Component'); + + expect(transformer.predicate(classExtending('Some.Component', 'Props', inTsx, fromOtherPackage))).to.equal(false); + expect(transformer.predicate(classExtending('Component', 'Props', inTsx, fromOtherComponent))).to.equal(false); + expect(transformer.predicate(classExtending('Component', 'Props', inTsx, fromThisFile))).to.equal(false); + expect(transformer.predicate(classExtending('React.Component', 'Props', inTsx, unresolved))).to.equal(true); + }); }); diff --git a/scopes/react/react/react.api.transformer.ts b/scopes/react/react/react.api.transformer.ts index efee9bcb4ca9..9483fa9f7c59 100644 --- a/scopes/react/react/react.api.transformer.ts +++ b/scopes/react/react/react.api.transformer.ts @@ -90,7 +90,17 @@ export class ReactAPITransformer implements SchemaNodeTransformer { private reactBaseOf(node: ClassSchema): ExpressionWithTypeArgumentsSchema | undefined { if (!this.isReactFile(node)) return undefined; - return node.extendsNodes?.find((base) => REACT_BASE_CLASS.test(base.name)); + return node.extendsNodes?.find((base) => REACT_BASE_CLASS.test(base.name) && this.isFromReact(base.expression)); + } + + /** + * the base must be React's — resolved to the `react` package, or not resolved at all. a base that resolved + * to another package, another component or a file of this one is some other `Component`. + */ + private isFromReact(expression: SchemaNode) { + if (!TypeRefSchema.isTypeRefSchema(expression)) return true; + if (expression.packageName) return expression.packageName === 'react'; + return !expression.componentId && !expression.internalFilePath; } private isReactFile(node: SchemaNode) {