-
-
Notifications
You must be signed in to change notification settings - Fork 10.1k
React: Fix manifest RDT for referenced tsconfig projects #34415
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kasperpeulen
wants to merge
8
commits into
next
Choose a base branch
from
kasper/react-manifest-rdt-tsconfig
base: next
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
019bf38
React: fix manifest react-docgen-typescript tsconfig resolution
kasperpeulen bc91daa
React: add parser tests for manifest RDT
kasperpeulen ca072fa
React: reuse referenced tsconfig selection for docgen
kasperpeulen 0bf2f03
React: document tsconfig selection inspiration
kasperpeulen 71c61aa
React: fix file-aware argtypes tsconfig resolution
kasperpeulen 2b6188f
React: fix manifest RDT test typing
kasperpeulen f793b74
React: fix shared tsconfig path test mocks
kasperpeulen 9e796a4
React: cache manifest tsconfig lookup
kasperpeulen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { dirname, join } from 'node:path'; | ||
|
|
||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { findTsconfigPathForFile } from '../tsconfig'; | ||
| import * as paths from '../paths'; | ||
|
|
||
| const tempDirs: string[] = []; | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
|
|
||
| for (const dir of tempDirs.splice(0)) { | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| describe('findTsconfigPathForFile', () => { | ||
| it('uses the referenced app tsconfig for Vite-style project references', () => { | ||
| const dir = createTempProject({ | ||
| 'tsconfig.json': JSON.stringify({ | ||
| files: [], | ||
| references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }], | ||
| }), | ||
| 'tsconfig.app.json': JSON.stringify({ | ||
| compilerOptions: { | ||
| baseUrl: '.', | ||
| paths: { | ||
| '@ui/*': ['src/*'], | ||
| }, | ||
| }, | ||
| include: ['src'], | ||
| }), | ||
| 'tsconfig.node.json': JSON.stringify({ | ||
| include: ['vite.config.ts'], | ||
| }), | ||
| 'src/Button.tsx': 'export const Button = () => null;', | ||
| }); | ||
|
|
||
| vi.spyOn(paths, 'getProjectRoot').mockReturnValue(dir); | ||
|
|
||
| expect(findTsconfigPathForFile(dir, join(dir, 'src/Button.tsx'))).toBe( | ||
| join(dir, 'tsconfig.app.json') | ||
| ); | ||
| }); | ||
|
|
||
| it('keeps reference order for same-directory sibling tsconfigs that both match the file', () => { | ||
| const dir = createTempProject({ | ||
| 'tsconfig.json': JSON.stringify({ | ||
| files: [], | ||
| references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }], | ||
| }), | ||
| 'tsconfig.app.json': JSON.stringify({ | ||
| compilerOptions: { | ||
| baseUrl: '.', | ||
| }, | ||
| include: ['src'], | ||
| }), | ||
| 'tsconfig.node.json': JSON.stringify({ | ||
| compilerOptions: { | ||
| module: 'ESNext', | ||
| }, | ||
| }), | ||
| 'src/Button.tsx': 'export const Button = () => null;', | ||
| }); | ||
|
|
||
| vi.spyOn(paths, 'getProjectRoot').mockReturnValue(dir); | ||
|
|
||
| expect(findTsconfigPathForFile(dir, join(dir, 'src/Button.tsx'))).toBe( | ||
| join(dir, 'tsconfig.app.json') | ||
| ); | ||
| }); | ||
|
|
||
| it('falls back to the nearest discovered tsconfig when no reference matches the file', () => { | ||
| const dir = createTempProject({ | ||
| 'tsconfig.json': JSON.stringify({ | ||
| compilerOptions: { | ||
| baseUrl: '.', | ||
| }, | ||
| }), | ||
| 'src/Button.tsx': 'export const Button = () => null;', | ||
| }); | ||
|
|
||
| vi.spyOn(paths, 'getProjectRoot').mockReturnValue(dir); | ||
|
|
||
| expect(findTsconfigPathForFile(dir, join(dir, 'src/Button.tsx'))).toBe( | ||
| join(dir, 'tsconfig.json') | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| function createTempProject(files: Record<string, string>) { | ||
| const dir = mkdtempSync(join(tmpdir(), 'storybook-tsconfig-')); | ||
| tempDirs.push(dir); | ||
|
|
||
| for (const [relativePath, content] of Object.entries(files)) { | ||
| const fullPath = join(dir, relativePath); | ||
| mkdirSync(dirname(fullPath), { recursive: true }); | ||
| writeFileSync(fullPath, content, 'utf-8'); | ||
| } | ||
|
|
||
| return dir; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| import { readFileSync } from 'node:fs'; | ||
| import { basename, dirname, relative, resolve } from 'node:path'; | ||
|
|
||
| import * as find from 'empathic/find'; | ||
| import picomatch from 'picomatch'; | ||
| import stripJsonComments from 'strip-json-comments'; | ||
|
|
||
| import { getProjectRoot } from './paths'; | ||
|
|
||
| const TSCONFIG_CANDIDATES = ['tsconfig.json', 'tsconfig.base.json', 'tsconfig.app.json'] as const; | ||
| const DEFAULT_EXCLUDE_PATTERNS = ['node_modules', 'bower_components', 'jspm_packages']; | ||
|
|
||
| type TsconfigReference = { path?: unknown }; | ||
| type TsconfigConfig = { | ||
| exclude?: unknown; | ||
| files?: unknown; | ||
| include?: unknown; | ||
| references?: unknown; | ||
| }; | ||
|
|
||
| type TsconfigEntry = { | ||
| config: TsconfigConfig; | ||
| path: string; | ||
| }; | ||
|
|
||
| export const findTsconfigPath = (cwd: string): string | undefined => { | ||
| const projectRoot = getProjectRoot(); | ||
|
|
||
| for (const candidate of TSCONFIG_CANDIDATES) { | ||
| const found = find.up(candidate, { cwd, last: projectRoot }); | ||
| if (found) { | ||
| return found; | ||
| } | ||
| } | ||
|
kasperpeulen marked this conversation as resolved.
|
||
|
|
||
| return undefined; | ||
| }; | ||
|
|
||
| /** | ||
| * Pick the tsconfig that actually applies to a given file, following project references when the | ||
| * nearest root config is just a references shell (for example Vite's `files: []` root tsconfig). | ||
| * | ||
| * This intentionally follows the same idea as the Volar-inspired selection logic used by | ||
| * `ComponentMetaManager`: prefer the config that really owns the file instead of stopping at the | ||
| * first config filename we discover. It extends the simpler fallback-chain fix from #34353 so | ||
| * docgen-style callers can handle project references too. | ||
| */ | ||
| export const findTsconfigPathForFile = (cwd: string, filePath: string): string | undefined => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a bit confused, there's also a |
||
| const rootTsconfigPath = findTsconfigPath(cwd); | ||
| if (!rootTsconfigPath) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const absoluteFilePath = resolve(filePath); | ||
| const matchingConfigs = collectTsconfigEntries(rootTsconfigPath, new Set()).filter((entry) => | ||
| tsconfigIncludesFile(entry, absoluteFilePath) | ||
| ); | ||
|
|
||
| matchingConfigs.sort((left, right) => compareTsconfigEntries(absoluteFilePath, left, right)); | ||
|
|
||
| return matchingConfigs[0]?.path ?? rootTsconfigPath; | ||
| }; | ||
|
|
||
| function collectTsconfigEntries(configPath: string, seen: Set<string>): TsconfigEntry[] { | ||
| const normalizedConfigPath = resolve(configPath); | ||
| if (seen.has(normalizedConfigPath)) { | ||
| return []; | ||
| } | ||
| seen.add(normalizedConfigPath); | ||
|
|
||
| const config = readTsconfigConfig(normalizedConfigPath); | ||
| if (!config) { | ||
| return []; | ||
| } | ||
|
|
||
| return [ | ||
| { path: normalizedConfigPath, config }, | ||
| ...getReferencedTsconfigPaths(normalizedConfigPath, config).flatMap((referencePath) => | ||
| collectTsconfigEntries(referencePath, seen) | ||
| ), | ||
| ]; | ||
| } | ||
|
|
||
| function getReferencedTsconfigPaths(configPath: string, config: TsconfigConfig) { | ||
| const references = Array.isArray(config.references) | ||
| ? (config.references as TsconfigReference[]) | ||
| : []; | ||
|
|
||
| return references | ||
| .map((reference) => reference.path) | ||
| .filter((referencePath): referencePath is string => typeof referencePath === 'string') | ||
| .map((referencePath) => resolve(dirname(configPath), referencePath)) | ||
| .flatMap((referencePath) => resolveReferenceConfigPaths(referencePath)); | ||
| } | ||
|
|
||
| function resolveReferenceConfigPaths(referencePath: string) { | ||
| if (referencePath.endsWith('.json')) { | ||
| return [referencePath]; | ||
| } | ||
|
|
||
| return TSCONFIG_CANDIDATES.map((candidate) => resolve(referencePath, candidate)); | ||
| } | ||
|
|
||
| function tsconfigIncludesFile(entry: TsconfigEntry, filePath: string) { | ||
| const configDir = dirname(entry.path); | ||
| const relativeFilePath = normalizePath(relative(configDir, filePath)); | ||
| if (relativeFilePath.startsWith('../') || relativeFilePath === '..') { | ||
| return false; | ||
| } | ||
|
|
||
| const files = asStringArray(entry.config.files); | ||
| if (files.length > 0) { | ||
| return files.some((candidatePath) => resolve(configDir, candidatePath) === filePath); | ||
| } | ||
|
|
||
| const includePatterns = getIncludePatterns(entry.config); | ||
| if (includePatterns.length === 0) { | ||
| return false; | ||
| } | ||
|
|
||
| const excludePatterns = [...DEFAULT_EXCLUDE_PATTERNS, ...asStringArray(entry.config.exclude)].map( | ||
| normalizeTsconfigPattern | ||
| ); | ||
|
|
||
| if (matchesPatterns(relativeFilePath, excludePatterns)) { | ||
| return false; | ||
| } | ||
|
|
||
| return matchesPatterns(relativeFilePath, includePatterns); | ||
| } | ||
|
|
||
| function getIncludePatterns(config: TsconfigConfig) { | ||
| const includes = asStringArray(config.include); | ||
| if (includes.length > 0) { | ||
| return includes.map(normalizeTsconfigPattern); | ||
| } | ||
|
|
||
| const files = asStringArray(config.files); | ||
| if (files.length > 0) { | ||
| return files.map(normalizePath); | ||
| } | ||
|
|
||
| if (Array.isArray(config.references) && files.length === 0) { | ||
| return []; | ||
| } | ||
|
|
||
| return ['**/*']; | ||
| } | ||
|
|
||
| function compareTsconfigEntries(filePath: string, left: TsconfigEntry, right: TsconfigEntry) { | ||
| const leftDir = dirname(left.path); | ||
| const rightDir = dirname(right.path); | ||
| const leftDepth = normalizePath(relative(leftDir, filePath)).split('/').length; | ||
| const rightDepth = normalizePath(relative(rightDir, filePath)).split('/').length; | ||
|
|
||
| if (leftDepth !== rightDepth) { | ||
| return leftDepth - rightDepth; | ||
| } | ||
|
|
||
| const leftIsPrimaryTsconfig = basename(left.path) === 'tsconfig.json' ? 1 : 0; | ||
| const rightIsPrimaryTsconfig = basename(right.path) === 'tsconfig.json' ? 1 : 0; | ||
|
|
||
| return rightIsPrimaryTsconfig - leftIsPrimaryTsconfig; | ||
| } | ||
|
|
||
| function readTsconfigConfig(configPath: string): TsconfigConfig | undefined { | ||
| try { | ||
| const content = readFileSync(configPath, 'utf-8'); | ||
| return JSON.parse(stripJsonComments(content)) as TsconfigConfig; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| function matchesPatterns(filePath: string, patterns: string[]) { | ||
| return patterns.some((pattern) => picomatch(pattern, { dot: true })(filePath)); | ||
| } | ||
|
|
||
| function normalizeTsconfigPattern(pattern: string) { | ||
| const normalizedPattern = normalizePath(pattern); | ||
| if (normalizedPattern.endsWith('/')) { | ||
| return `${normalizedPattern}**/*`; | ||
| } | ||
|
|
||
| if (!picomatch.scan(normalizedPattern).isGlob && !/\.[^/]+$/.test(normalizedPattern)) { | ||
| return `${normalizedPattern}/**/*`; | ||
| } | ||
|
|
||
| return normalizedPattern; | ||
|
kasperpeulen marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function normalizePath(value: string) { | ||
| return value.replaceAll('\\', '/'); | ||
| } | ||
|
|
||
| function asStringArray(value: unknown) { | ||
| return Array.isArray(value) | ||
| ? value.filter((item): item is string => typeof item === 'string') | ||
| : []; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can directly import { up } as it's treeshakable