From 333a2049121cc067405c4ad51466e50cdc47818d Mon Sep 17 00:00:00 2001 From: ia319 Date: Thu, 14 May 2026 17:44:03 +0800 Subject: [PATCH 1/2] fix(react): resolve RDT referenced tsconfigs - select the tsconfig that contains each component file - follow project references when the root config has no source files - cache parsers by tsconfig and react-docgen-typescript options - index parsed tsconfig file names to avoid repeated linear scans --- .../reactDocgenTypescript.ts | 237 +++++++++++++----- 1 file changed, 178 insertions(+), 59 deletions(-) diff --git a/code/renderers/react/src/componentManifest/reactDocgenTypescript.ts b/code/renderers/react/src/componentManifest/reactDocgenTypescript.ts index 1820e9ffc335..7bb19f9a7bb9 100644 --- a/code/renderers/react/src/componentManifest/reactDocgenTypescript.ts +++ b/code/renderers/react/src/componentManifest/reactDocgenTypescript.ts @@ -1,4 +1,4 @@ -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; import { type ComponentDoc, @@ -163,79 +163,198 @@ function getExportNameMap( } /** - * Manages the TS program and react-docgen-typescript parser. On `invalidateParser()` the program is + * Manages TS programs and react-docgen-typescript parsers. On `invalidateParser()` programs are * rebuilt incrementally — TypeScript reuses source files that haven't changed on disk, so only * modified files are re-parsed. This keeps prop extraction correct across HMR cycles without the * cost of a full program rebuild. */ -let cachedCompilerOptions: ts.CompilerOptions | undefined; -let cachedFileNames: string[] | undefined; -let previousProgram: ts.Program | undefined; -let parser: { program: ts.Program; fileParser: FileParser } | undefined; -let cachedParserOptionsKey: string | undefined; +type ParsedTsconfig = { + configPath: string; + fileNames: Set; + parsed: ts.ParsedCommandLine; +}; + +type CachedParser = { + program: ts.Program; + fileParser: FileParser; +}; + +const previousPrograms = new Map(); +let parsers = new Map(); +let parsedTsconfigs = new Map(); +let tsconfigByFile = new Map(); /** Rebuild the TS program incrementally so that file changes are picked up on the next parse. */ export function invalidateParser() { - parser = undefined; - cachedCompilerOptions = undefined; - cachedFileNames = undefined; - cachedParserOptionsKey = undefined; + parsers = new Map(); + parsedTsconfigs = new Map(); + tsconfigByFile = new Map(); +} + +const normalizePath = (filePath: string) => filePath.replace(/\\/g, '/'); + +const getCanonicalFileName = (typescript: TypeScriptRuntime, filePath: string) => + normalizePath(typescript.sys.useCaseSensitiveFileNames ? filePath : filePath.toLowerCase()); + +function getProjectReferenceConfigPath( + typescript: TypeScriptRuntime, + referencePath: string +): string | undefined { + if (typescript.sys.fileExists(referencePath)) { + return referencePath; + } + + if (typescript.sys.directoryExists(referencePath)) { + const tsconfigPath = join(referencePath, 'tsconfig.json'); + if (typescript.sys.fileExists(tsconfigPath)) { + return tsconfigPath; + } + } + + const jsonPath = `${referencePath}.json`; + return typescript.sys.fileExists(jsonPath) ? jsonPath : undefined; } -async function getParser(userOptions?: ParserOptions) { +function parseTsconfig( + typescript: TypeScriptRuntime, + configPath: string +): ParsedTsconfig | undefined { + const cached = parsedTsconfigs.get(configPath); + if (cached || parsedTsconfigs.has(configPath)) { + return cached; + } + + const { config, error } = typescript.readConfigFile(configPath, typescript.sys.readFile); + if (error) { + parsedTsconfigs.set(configPath, undefined); + return undefined; + } + + const parsed = typescript.parseJsonConfigFileContent( + config, + typescript.sys, + dirname(configPath), + undefined, + configPath + ); + const fileNames = new Set( + parsed.fileNames.map((fileName) => getCanonicalFileName(typescript, fileName)) + ); + const result = { configPath, fileNames, parsed }; + for (const fileName of fileNames) { + tsconfigByFile.set(fileName, result); + } + parsedTsconfigs.set(configPath, result); + return result; +} + +function findReferencedTsconfigForFile( + typescript: TypeScriptRuntime, + config: ParsedTsconfig, + filePath: string, + seen = new Set() +): ParsedTsconfig | undefined { + if (seen.has(config.configPath)) { + return undefined; + } + seen.add(config.configPath); + + for (const reference of config.parsed.projectReferences ?? []) { + const referenceConfigPath = getProjectReferenceConfigPath(typescript, reference.path); + if (!referenceConfigPath) { + continue; + } + + const referenceConfig = parseTsconfig(typescript, referenceConfigPath); + if (!referenceConfig) { + continue; + } + + if (referenceConfig.fileNames.has(filePath)) { + return referenceConfig; + } + + const nestedConfig = findReferencedTsconfigForFile(typescript, referenceConfig, filePath, seen); + if (nestedConfig) { + return nestedConfig; + } + } + + return undefined; +} + +function findTsconfigForFile( + typescript: TypeScriptRuntime, + filePath: string +): ParsedTsconfig | undefined { + const canonicalFilePath = getCanonicalFileName(typescript, filePath); + const cachedConfig = tsconfigByFile.get(canonicalFilePath); + if (cachedConfig) { + return cachedConfig; + } + + const configPath = findTsconfigPath(process.cwd()); + if (!configPath) { + return undefined; + } + + const rootConfig = parseTsconfig(typescript, configPath); + if (!rootConfig) { + return undefined; + } + + if (rootConfig.fileNames.has(canonicalFilePath)) { + return rootConfig; + } + + return findReferencedTsconfigForFile(typescript, rootConfig, canonicalFilePath) ?? rootConfig; +} + +async function getParser(filePath: string, userOptions?: ParserOptions) { const [typescript, reactDocgenTypescript] = await Promise.all([ loadTypeScript(), loadReactDocgenTypescript(), ]); - // Rebuild parser if options changed - const optionsKey = JSON.stringify(userOptions ?? {}); - if (parser && cachedParserOptionsKey !== optionsKey) { - parser = undefined; - } - - if (!parser) { - const configPath = findTsconfigPath(process.cwd()); - cachedCompilerOptions = { noErrorTruncation: true, strict: true }; - - if (configPath) { - const { config } = typescript.readConfigFile(configPath, typescript.sys.readFile); - const parsed = typescript.parseJsonConfigFileContent( - config, - typescript.sys, - dirname(configPath) - ); - cachedCompilerOptions = { ...parsed.options, noErrorTruncation: true }; - cachedFileNames = parsed.fileNames; - } else { - logger.warn( - 'No tsconfig.json (or tsconfig.base.json / tsconfig.app.json) found. ' + - 'TypeScript component props will not be documented by react-docgen-typescript. ' + - 'Create a tsconfig.json in your project root to enable automatic controls.' - ); - } - const program = typescript.createProgram( - cachedFileNames ?? [], - cachedCompilerOptions, - undefined, - previousProgram + const config = findTsconfigForFile(typescript, filePath); + if (!config) { + logger.warn( + 'No tsconfig.json (or tsconfig.base.json / tsconfig.app.json) found. ' + + 'TypeScript component props will not be documented by react-docgen-typescript. ' + + 'Create a tsconfig.json in your project root to enable automatic controls.' ); - previousProgram = program; - - const parserOptions: ParserOptions = { - shouldExtractLiteralValuesFromEnum: true, - shouldRemoveUndefinedFromOptional: true, - ...userOptions, - // Always force savePropValueAsString so default values are in a consistent format - savePropValueAsString: true, - }; + } - parser = { - program, - fileParser: reactDocgenTypescript.withCompilerOptions(cachedCompilerOptions, parserOptions), - }; - cachedParserOptionsKey = optionsKey; + const parserOptionsKey = JSON.stringify(userOptions ?? {}); + const parserKey = JSON.stringify([config?.configPath ?? '', parserOptionsKey]); + const existingParser = parsers.get(parserKey); + if (existingParser) { + return { ...existingParser, typescript }; } + + const compilerOptions = { ...(config?.parsed.options ?? {}), noErrorTruncation: true }; + const previousProgram = previousPrograms.get(parserKey); + const program = typescript.createProgram( + config?.parsed.fileNames ?? [], + compilerOptions, + undefined, + previousProgram + ); + previousPrograms.set(parserKey, program); + + const parserOptions: ParserOptions = { + shouldExtractLiteralValuesFromEnum: true, + shouldRemoveUndefinedFromOptional: true, + ...userOptions, + // Always force savePropValueAsString so default values are in a consistent format + savePropValueAsString: true, + }; + + const parser = { + program, + fileParser: reactDocgenTypescript.withCompilerOptions(compilerOptions, parserOptions), + }; + parsers.set(parserKey, parser); return { ...parser, typescript }; } @@ -295,11 +414,11 @@ export function getReactDocgenTypescriptError( /** * Parse a component file with react-docgen-typescript. Per-file results are cached via - * `invalidateCache()`. The underlying TS program is a long-lived singleton. + * `invalidateCache()`. TS programs are cached by the selected tsconfig and parser options. */ export const parseWithReactDocgenTypescript = asyncCache( async (filePath: string, userOptions?: ParserOptions): Promise => { - const { program, fileParser, typescript } = await getParser(userOptions); + const { program, fileParser, typescript } = await getParser(filePath, userOptions); const checker = program.getTypeChecker(); const sourceFile = program.getSourceFile(filePath); From c35f2f63286ccc7f119e1f32a98880250365ebc7 Mon Sep 17 00:00:00 2001 From: ia319 Date: Thu, 14 May 2026 18:30:09 +0800 Subject: [PATCH 2/2] test(react): cover RDT referenced tsconfig prop extraction --- .../reactDocgenTypescript.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 code/renderers/react/src/componentManifest/reactDocgenTypescript.test.ts diff --git a/code/renderers/react/src/componentManifest/reactDocgenTypescript.test.ts b/code/renderers/react/src/componentManifest/reactDocgenTypescript.test.ts new file mode 100644 index 000000000000..26f77aec763c --- /dev/null +++ b/code/renderers/react/src/componentManifest/reactDocgenTypescript.test.ts @@ -0,0 +1,100 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { dedent } from 'ts-dedent'; + +import { cleanup, createTempDir } from './componentMeta/test-helpers.ts'; +import { invalidateParser, parseWithReactDocgenTypescript } from './reactDocgenTypescript.ts'; +import { invalidateCache } from './utils.ts'; + +const originalCwd = process.cwd(); + +function writeFiles(baseDir: string, files: Record) { + const filePaths: Record = {}; + for (const [name, content] of Object.entries(files)) { + const filePath = path.join(baseDir, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf-8'); + filePaths[name] = filePath; + } + return filePaths; +} + +describe('parseWithReactDocgenTypescript', () => { + let tempDir: string | undefined; + + beforeEach(() => { + invalidateCache(); + invalidateParser(); + }); + + afterEach(() => { + process.chdir(originalCwd); + invalidateCache(); + invalidateParser(); + + if (tempDir) { + cleanup(tempDir); + tempDir = undefined; + } + }); + + it('uses a referenced tsconfig that contains the component file', async () => { + tempDir = createTempDir('react-docgen-typescript-test'); + const files = writeFiles(tempDir, { + 'tsconfig.json': JSON.stringify({ + files: [], + references: [{ path: './tsconfig.app.json' }], + }), + 'tsconfig.base.json': JSON.stringify({ + compilerOptions: { + target: 'ES2020', + module: 'ESNext', + jsx: 'react-jsx', + strict: true, + esModuleInterop: true, + moduleResolution: 'bundler', + }, + }), + 'tsconfig.app.json': JSON.stringify({ + extends: './tsconfig.base.json', + compilerOptions: { + composite: true, + }, + include: ['src'], + }), + 'src/Button.tsx': dedent` + export type ButtonProps = { + label: string; + primary?: boolean; + }; + + export function Button({ label }: ButtonProps) { + return ; + } + `, + }); + + process.chdir(tempDir); + + const docs = await parseWithReactDocgenTypescript(files['src/Button.tsx']); + + expect(docs).toHaveLength(1); + expect(docs[0]).toMatchObject({ + displayName: 'Button', + exportName: 'Button', + props: { + label: { + required: true, + type: { name: 'string' }, + }, + primary: { + required: false, + type: { name: 'boolean' }, + }, + }, + }); + }); +});