Skip to content

Commit 39751d8

Browse files
gabrieldonadelmeta-codesync[bot]
authored andcommitted
fix(codegen): skip node_modules and symlinks when crawling for components (#58518)
Summary: `findFilesWithExtension` walks a library's directory looking for `.mm` files that declare a Fabric component. The walk descends into every subdirectory, `node_modules` included, resolves symlinks through `statSync`, and keeps no visited set. This got broken after #57790 because `parseiOSAnnotations` now enters every library that declares a `codegenConfig` without an `ios` key into its map with an empty `components` object, so nothing removes it from `librariesToCrawl`. Previously such libraries were filtered before crawling. An app that declares `codegenConfig` with `"type": "all"` and no `ios` key now gets treated as a component library, so the walk covers the entire project. What we notice in the [expo repo](expo/expo#50134) is that under pnpm, the traversal never terminates. Workspace packages link into each other's `node_modules` and form cycles. Because symlinks are followed, the walk only stops once paths hit the 1023 byte limit. CocoaPods progress stops right after the "Using React Native Core and React Native Dependencies prebuilt versions" line while a `generate-codegen-artifacts.js` child sits at 100% CPU. Under npm and yarn, the walk completes but with the wrong result. In those cases, `node_modules` holds real directories, but the walk covers the whole dependency tree. Crawling one app of roughly 1100 packages turned up 382 `.mm` files, 43 of which declare a component, among them React core views such as `RCTImageComponentView` and `RCTScrollViewComponentView` from `react-native-macos`. Each one is written into the app's entry in `RCTThirdPartyComponentsProvider.mm`. The existing `/react-native/` path filter does not exclude them, since it requires a trailing separator and `react-native-macos` has none. ## Changelog: [IOS] [FIXED] - Codegen no longer crawls `node_modules` or follows symlinks when discovering components Pull Request resolved: #58518 Test Plan: Two cases added to `packages/react-native/scripts/codegen/__tests__/generate-artifacts-executor-test.js`. The three existing `findFilesWithExtension` mocks move from `statSync` to `lstatSync`. The symlink case builds a link pointing back at its own parent, so the pre-fix walk never terminates. **Negative control:** with the `generateRCTThirdPartyComponents.js` change reverted and the tests left in place, both new cases fail. All 24 existing snapshots pass unchanged, so the change is additive for projects that already declare an `ios` config. Measured by crawling the app directory of two real projects: | Project | Before | After | | --- | --- | --- | | `apps/bare-expo`, pnpm | 116,800 reads in 30s, still running | 34 files, 1.1s | | yarn app, ~1100 packages | 382 files, 5.8s | 8 files, 0.3s | Reviewed By: fabriziocucci Differential Revision: D120122552 Pulled By: vzaidman fbshipit-source-id: 233b8dc338b92372a34a0855f12a549e52513200
1 parent dcc872a commit 39751d8

2 files changed

Lines changed: 85 additions & 7 deletions

File tree

packages/react-native/scripts/codegen/__tests__/generate-artifacts-executor-test.js

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -553,8 +553,9 @@ describe('findFilesWithExtension', () => {
553553
return [];
554554
},
555555
existsSync: () => true,
556-
statSync: () => ({
556+
lstatSync: () => ({
557557
isDirectory: () => false,
558+
isSymbolicLink: () => false,
558559
}),
559560
readFileSync: () => packageJson,
560561
}));
@@ -586,11 +587,12 @@ describe('findFilesWithExtension', () => {
586587
return [];
587588
},
588589
existsSync: () => true,
589-
statSync: filePath => ({
590+
lstatSync: filePath => ({
590591
isDirectory: () =>
591592
filePath === pnpmFolder ||
592593
filePath === packageFolder ||
593594
filePath === path.join(targetFolder, '.hidden'),
595+
isSymbolicLink: () => false,
594596
}),
595597
readFileSync: () => packageJson,
596598
}));
@@ -619,8 +621,9 @@ describe('findFilesWithExtension', () => {
619621
return [];
620622
},
621623
existsSync: () => true,
622-
statSync: filePath => ({
624+
lstatSync: filePath => ({
623625
isDirectory: () => filePath === path.join(targetFolder, 'Components'),
626+
isSymbolicLink: () => false,
624627
}),
625628
readFileSync: () => packageJson,
626629
}));
@@ -635,6 +638,67 @@ describe('findFilesWithExtension', () => {
635638
path.join(targetFolder, 'Components', 'MyComponent.mm'),
636639
]);
637640
});
641+
642+
it('skips nested node_modules folders', () => {
643+
const targetFolder = '/project/my-library';
644+
const nodeModules = path.join(targetFolder, 'node_modules');
645+
646+
jest.mock('node:fs', () => ({
647+
readdirSync: dirPath => {
648+
if (dirPath === targetFolder) {
649+
return ['node_modules', 'Component.mm'];
650+
}
651+
if (dirPath === nodeModules) {
652+
return ['Dependency.mm'];
653+
}
654+
return [];
655+
},
656+
existsSync: () => true,
657+
lstatSync: filePath => ({
658+
isDirectory: () => filePath === nodeModules,
659+
isSymbolicLink: () => false,
660+
}),
661+
readFileSync: () => packageJson,
662+
}));
663+
664+
const {
665+
findFilesWithExtension: findFiles,
666+
} = require('../generate-artifacts-executor/generateRCTThirdPartyComponents');
667+
668+
const result = findFiles(targetFolder, '.mm');
669+
expect(result).toEqual([path.join(targetFolder, 'Component.mm')]);
670+
});
671+
672+
it('does not follow symlinked folders', () => {
673+
const targetFolder = '/project/my-library';
674+
const symlinkedFolder = path.join(targetFolder, 'linked');
675+
676+
jest.mock('node:fs', () => ({
677+
readdirSync: dirPath => {
678+
if (dirPath === targetFolder) {
679+
return ['linked', 'Component.mm'];
680+
}
681+
// A symlink pointing back at its parent: following it never terminates.
682+
if (dirPath === symlinkedFolder) {
683+
return ['linked', 'Component.mm'];
684+
}
685+
return [];
686+
},
687+
existsSync: () => true,
688+
lstatSync: filePath => ({
689+
isDirectory: () => filePath.endsWith('linked'),
690+
isSymbolicLink: () => filePath.endsWith('linked'),
691+
}),
692+
readFileSync: () => packageJson,
693+
}));
694+
695+
const {
696+
findFilesWithExtension: findFiles,
697+
} = require('../generate-artifacts-executor/generateRCTThirdPartyComponents');
698+
699+
const result = findFiles(targetFolder, '.mm');
700+
expect(result).toEqual([path.join(targetFolder, 'Component.mm')]);
701+
});
638702
});
639703

640704
describe('generateSchemaInfos', () => {

packages/react-native/scripts/codegen/generate-artifacts-executor/generateRCTThirdPartyComponents.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,24 @@ function findFilesWithExtension(
177177
return null;
178178
}
179179

180-
if (
181-
fs.existsSync(absolutePath) &&
182-
fs.statSync(absolutePath).isDirectory()
183-
) {
180+
// A library's own sources never live in its dependencies, and crawling them
181+
// is what makes this walk explode on large projects.
182+
if (file === 'node_modules') {
183+
return null;
184+
}
185+
186+
if (!fs.existsSync(absolutePath)) {
187+
return null;
188+
}
189+
190+
// `lstatSync` does not resolve symlinks: following them can loop forever,
191+
// e.g. workspace packages that link into each other under pnpm.
192+
const stats = fs.lstatSync(absolutePath);
193+
if (stats.isSymbolicLink()) {
194+
return null;
195+
}
196+
197+
if (stats.isDirectory()) {
184198
files.push(...findFilesWithExtension(absolutePath, extension));
185199
} else if (file.endsWith(extension)) {
186200
files.push(absolutePath);

0 commit comments

Comments
 (0)