diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js index d8579625339..6cf8b11e175 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js @@ -10,13 +10,16 @@ 'use strict'; +const headers = require('../headers'); const { NATURAL_PATH_SOURCE_PREFERENCES, PLATFORM_DISPATCH_AUXILIARY_HEADERS, PLATFORM_DISPATCH_IMPLEMENTATIONS, + buildInventory, computeInventory, scanHeader, } = require('../headers-inventory'); +const fs = require('fs'); const path = require('path'); describe('scanHeader include classification', () => { @@ -130,7 +133,20 @@ describe('header source precedence', () => { const inventory = computeInventory(rnRoot); expect(inventory.collisions).toEqual([]); - for (const [naturalPath, source] of NATURAL_PATH_SOURCE_PREFERENCES) { + expect(Array.from(PLATFORM_DISPATCH_IMPLEMENTATIONS.keys())).toEqual( + [ + 'HostPlatformTouch.h', + 'HostPlatformViewEventEmitter.h', + 'HostPlatformViewProps.h', + 'HostPlatformViewTraitsInitializer.h', + 'KeyEvent.h', + 'MouseEvent.h', + ].map(name => `react/renderer/components/view/${name}`), + ); + for (const [ + naturalPath, + {preferredSource: source}, + ] of NATURAL_PATH_SOURCE_PREFERENCES) { const header = inventory.headers.find( candidate => candidate.naturalPath === naturalPath, ); @@ -156,19 +172,252 @@ describe('header source precedence', () => { source, ]); } + const expectedConsumers = [ + 'RCTText/RCTUITextField.h', + 'RCTText/RCTUITextView.h', + 'RCTText/RCTWrappedTextView.h', + 'React/RCTUITextField.h', + 'React/RCTUITextView.h', + 'React/RCTUnimplementedNativeComponentView.h', + 'React/RCTWrappedTextView.h', + ]; const textUIKitConsumers = inventory.headers.filter(header => - [ - 'RCTText/RCTUITextField.h', - 'RCTText/RCTUITextView.h', - 'RCTText/RCTWrappedTextView.h', - 'React/RCTUITextField.h', - 'React/RCTUITextView.h', - 'React/RCTUnimplementedNativeComponentView.h', - 'React/RCTWrappedTextView.h', - ].includes(header.naturalPath), + expectedConsumers.includes(header.naturalPath), + ); + expect(textUIKitConsumers.map(header => header.naturalPath).sort()).toEqual( + expectedConsumers.sort(), ); expect( textUIKitConsumers.flatMap(header => header.includes.quotedNotShipped), ).toEqual([]); + const byPath = new Map(inventory.headers.map(h => [h.naturalPath, h])); + for (const [wrapperPath, source] of PLATFORM_DISPATCH_IMPLEMENTATIONS) { + const wrapper = byPath.get(wrapperPath); + expect(wrapper).toBeDefined(); + expect(wrapper?.includes.internal).toContainEqual({ + naturalPath: source.slice('ReactCommon/'.length), + cxxGuarded: false, + }); + if (NATURAL_PATH_SOURCE_PREFERENCES.has(wrapperPath)) { + const cxxPath = source.replace('/platform/macos/', '/platform/cxx/'); + expect(byPath.has(cxxPath.slice('ReactCommon/'.length))).toBe(true); + expect(wrapper?.includes.internal).toContainEqual({ + naturalPath: cxxPath.slice('ReactCommon/'.length), + cxxGuarded: false, + }); + } + } }); }); + +describe('bounded source exceptions', () => { + const root = '/header-inventory-fixture'; + const rules = Array.from(NATURAL_PATH_SOURCE_PREFERENCES); + const mockHeaders = (naturalPath, sources) => { + jest.spyOn(headers, 'getHeaderFilesFromPodspecs').mockReturnValue({ + fixture: [ + { + specName: 'React-Fabric', + headerDir: '', + headers: sources.map(source => ({ + source: path.join(root, source), + target: naturalPath, + })), + }, + ], + }); + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + }; + + afterEach(() => jest.restoreAllMocks()); + + test.each(rules)( + 'resolves only the exact known pair for %s', + (naturalPath, rule) => { + mockHeaders(naturalPath, [rule.competingSource, rule.preferredSource]); + const result = buildInventory(root); + expect(result.collisions).toEqual([]); + expect( + result.entries.get(naturalPath)?.identities.map(i => i.source), + ).toEqual([rule.preferredSource]); + expect( + result.sourceToNatural.get(path.join(root, rule.competingSource)), + ).not.toContain(naturalPath); + if (rule.competingSource.includes('/platform/')) { + expect( + result.entries.has(rule.competingSource.slice('ReactCommon/'.length)), + ).toBe(true); + } + }, + ); + + test.each(rules)( + 'preserves an unexpected third source for %s', + (naturalPath, rule) => { + const sources = [ + rule.preferredSource, + rule.competingSource, + 'unexpected/Header.h', + ]; + mockHeaders(naturalPath, sources); + const result = buildInventory(root); + expect(result.collisions).toEqual([ + {naturalPath, sources: [...sources].sort()}, + ]); + expect( + result.entries.get(naturalPath)?.identities.map(i => i.source), + ).toEqual(sources); + for (const source of sources) { + expect(result.sourceToNatural.get(path.join(root, source))).toContain( + naturalPath, + ); + } + }, + ); + + test.each(rules)( + 'does not suppress a replacement competitor for %s', + (naturalPath, rule) => { + const sources = [rule.preferredSource, 'unexpected/Header.h']; + mockHeaders(naturalPath, sources); + expect(buildInventory(root).collisions).toEqual([ + {naturalPath, sources: sources.sort()}, + ]); + }, + ); + + test.each(rules)( + 'keeps an upstream-only source for %s', + (naturalPath, rule) => { + const source = naturalPath.includes('/view/') + ? rule.competingSource + : rule.preferredSource; + mockHeaders(naturalPath, [source]); + const result = buildInventory(root); + expect(result.collisions).toEqual([]); + expect( + result.entries.get(naturalPath)?.identities.map(i => i.source), + ).toEqual([source]); + expect( + Array.from(result.entries.keys()).some(p => + p.includes('/platform/macos/'), + ), + ).toBe(false); + }, + ); + + test.each(rules)( + 'retains collisions when the preferred source is absent for %s', + (naturalPath, rule) => { + const sources = [rule.competingSource, 'unexpected/Header.h']; + mockHeaders(naturalPath, sources); + expect(buildInventory(root).collisions).toEqual([ + {naturalPath, sources: sources.sort()}, + ]); + }, + ); + + test.each(Array.from(PLATFORM_DISPATCH_IMPLEMENTATIONS))( + 'does not activate macOS additions for an unrelated source at %s', + (naturalPath, implementation) => { + mockHeaders(naturalPath, ['unexpected/Header.h']); + const result = buildInventory(root); + expect( + result.entries.has(implementation.slice('ReactCommon/'.length)), + ).toBe(false); + for (const source of PLATFORM_DISPATCH_AUXILIARY_HEADERS.keys()) { + expect(result.entries.has(source.slice('ReactCommon/'.length))).toBe( + false, + ); + } + }, + ); +}); + +describe('packaged quoted include resolution', () => { + const root = '/header-inventory-fixture'; + const classify = (token, targets, guarded = false) => { + jest.spyOn(headers, 'getHeaderFilesFromPodspecs').mockReturnValue({ + fixture: [ + { + specName: 'Fixture', + headerDir: '', + headers: [ + {source: path.join(root, 'source/A.h'), target: 'ns/A.h'}, + ...targets.map(([target, source]) => ({ + source: path.join(root, source), + target, + })), + ], + }, + ], + }); + jest.spyOn(fs, 'readFileSync').mockImplementation(file => { + if (file !== path.join(root, 'source/A.h')) { + return ''; + } + const include = `#include "${token}"\n`; + return guarded ? `#ifdef __cplusplus\n${include}#endif\n` : include; + }); + return computeInventory(root).headers.find(h => h.naturalPath === 'ns/A.h') + ?.includes; + }; + + afterEach(() => jest.restoreAllMocks()); + + test.each(['sub/B.h', './sub/B.h', 'sub/../sub/B.h', '../ns/sub/B.h'])( + 'normalizes the packaged sibling %s', + token => { + const includes = classify(token, [['ns/sub/B.h', 'elsewhere/B.h']]); + expect(includes?.internal).toEqual([ + {naturalPath: 'ns/sub/B.h', cxxGuarded: false}, + ]); + expect(includes?.quotedNotShipped).toEqual([]); + }, + ); + + test('prefers the packaged sibling over root and physical-source matches', () => { + const includes = classify( + 'sub/B.h', + [ + ['ns/sub/B.h', 'elsewhere/B.h'], + ['sub/B.h', 'root/B.h'], + ['relocated/B.h', 'source/sub/B.h'], + ], + true, + ); + expect(includes?.internal).toEqual([ + {naturalPath: 'ns/sub/B.h', cxxGuarded: true}, + ]); + }); + + test.each(['other/B.h', './other/B.h', 'other/sub/../B.h'])( + 'falls back to the normalized include-root spelling %s', + token => { + expect( + classify(token, [['other/B.h', 'elsewhere/B.h']])?.internal, + ).toEqual([{naturalPath: 'other/B.h', cxxGuarded: false}]); + }, + ); + + test('retains the source mapping for relocated pod headers', () => { + expect( + classify('B.h', [['relocated/B.h', 'source/B.h']])?.internal, + ).toEqual([{naturalPath: 'relocated/B.h', cxxGuarded: false}]); + }); + + test('resolves a bare name within the packaged namespace', () => { + expect(classify('B.h', [['ns/B.h', 'elsewhere/B.h']])?.internal).toEqual([ + {naturalPath: 'ns/B.h', cxxGuarded: false}, + ]); + }); + + test.each(['missing/B.h', '../../other/B.h', '/other/B.h'])( + 'does not invent a packaged target for %s', + token => { + const includes = classify(token, [['other/B.h', 'elsewhere/B.h']]); + expect(includes?.internal).toEqual([]); + expect(includes?.quotedNotShipped).toEqual([`"${token}"`]); + }, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-verify-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-verify-test.js new file mode 100644 index 00000000000..c13499f1afb --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-verify-test.js @@ -0,0 +1,51 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const inventory = require('../headers-inventory'); +const spec = require('../headers-spec'); +const {main} = require('../headers-verify'); +const fs = require('fs'); + +describe('headers-verify collision gates', () => { + afterEach(() => jest.restoreAllMocks()); + + test.each( + [[], ['--skip-compile'], ['--update-baseline']].map(argv => [argv]), + )( + 'rejects natural-path collisions before planning or baseline writes: %p', + argv => { + jest.spyOn(inventory, 'computeInventory').mockReturnValue({ + headers: [], + collisions: [ + {naturalPath: 'ns/A.h', sources: ['first/A.h', 'extra/A.h']}, + ], + }); + const plan = jest.spyOn(spec, 'planFromInventory'); + const write = jest.spyOn(fs, 'writeFileSync'); + expect(() => main(argv)).toThrow( + /natural-path collisions \(R8\):\n {2}ns\/A.h <- first\/A.h, extra\/A.h/, + ); + expect(plan).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }, + ); + + test('still rejects destination collisions after a clean inventory', () => { + jest + .spyOn(inventory, 'computeInventory') + .mockReturnValue({headers: [], collisions: []}); + jest + .spyOn(spec, 'planFromInventory') + .mockReturnValue({collisions: ['destination conflict']}); + expect(() => main([])).toThrow(/R8 collisions:\n {2}destination conflict/); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/headers-inventory.js b/packages/react-native/scripts/ios-prebuild/headers-inventory.js index 9f4cc270a08..27fb279faad 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-inventory.js +++ b/packages/react-native/scripts/ios-prebuild/headers-inventory.js @@ -41,10 +41,20 @@ const path = require('path'); // header explicitly. Legacy interop is the inverse: upstream's platform/ios // header is the guarded canonical implementation and already carries the macOS // type adaptations. -const NATURAL_PATH_SOURCE_PREFERENCES /*: Map */ = new Map([ +// Only these exact pairs are equivalent public spellings. An additional source +// must remain a collision, even when the preferred source is present. +const NATURAL_PATH_SOURCE_PREFERENCES /*: Map */ = new Map([ [ 'react/renderer/components/legacyviewmanagerinterop/RCTLegacyViewManagerInteropCoordinator.h', - 'ReactCommon/react/renderer/components/legacyviewmanagerinterop/platform/ios/react/renderer/components/legacyviewmanagerinterop/RCTLegacyViewManagerInteropCoordinator.h', + { + preferredSource: + 'ReactCommon/react/renderer/components/legacyviewmanagerinterop/platform/ios/react/renderer/components/legacyviewmanagerinterop/RCTLegacyViewManagerInteropCoordinator.h', + competingSource: + 'ReactCommon/react/renderer/components/legacyviewmanagerinterop/RCTLegacyViewManagerInteropCoordinator.h', + }, ], ...[ 'HostPlatformTouch.h', @@ -53,7 +63,10 @@ const NATURAL_PATH_SOURCE_PREFERENCES /*: Map */ = new Map([ 'HostPlatformViewTraitsInitializer.h', ].map(name => [ `react/renderer/components/view/${name}`, - `ReactCommon/react/renderer/components/view/${name}`, + { + preferredSource: `ReactCommon/react/renderer/components/view/${name}`, + competingSource: `ReactCommon/react/renderer/components/view/platform/cxx/react/renderer/components/view/${name}`, + }, ]), ]); @@ -460,18 +473,20 @@ function buildInventory(rootFolder /*: string */) /*: { implementationSource, ] of PLATFORM_DISPATCH_IMPLEMENTATIONS) { const wrapper = entries.get(wrapperPath); + const wrapperIdentity = wrapper?.identities.find( + identity => identity.source === `ReactCommon/${wrapperPath}`, + ); const implementationPath = implementationSource.slice( 'ReactCommon/'.length, ); const implementationAbsSource = path.join(rootFolder, implementationSource); if ( - wrapper == null || + wrapperIdentity == null || entries.has(implementationPath) || !fs.existsSync(implementationAbsSource) ) { continue; } - const wrapperIdentity = wrapper.identities[0]; addIdentity( implementationPath, { @@ -488,18 +503,20 @@ function buildInventory(rootFolder /*: string */) /*: { wrapperPath, ] of PLATFORM_DISPATCH_AUXILIARY_HEADERS) { const wrapper = entries.get(wrapperPath); + const wrapperIdentity = wrapper?.identities.find( + identity => identity.source === `ReactCommon/${wrapperPath}`, + ); const implementationPath = implementationSource.slice( 'ReactCommon/'.length, ); const implementationAbsSource = path.join(rootFolder, implementationSource); if ( - wrapper == null || + wrapperIdentity == null || entries.has(implementationPath) || !fs.existsSync(implementationAbsSource) ) { continue; } - const wrapperIdentity = wrapper.identities[0]; addIdentity( implementationPath, { @@ -513,14 +530,15 @@ function buildInventory(rootFolder /*: string */) /*: { for (const [ naturalPath, - preferredSource, + {preferredSource, competingSource}, ] of NATURAL_PATH_SOURCE_PREFERENCES) { const sources = naturalToSources.get(naturalPath); const preferredAbsSource = path.join(rootFolder, preferredSource); if ( sources == null || - sources.size < 2 || - !sources.has(preferredAbsSource) + sources.size !== 2 || + !sources.has(preferredAbsSource) || + !sources.has(path.join(rootFolder, competingSource)) ) { continue; } @@ -599,13 +617,32 @@ function classifyEntries( for (const inc of scan.includes) { let token = inc.token; - // Quoted include: resolve against the source dir and map back to a - // natural path if the resolved file is itself a shipped header. + // Quoted includes search the packaged sibling first, then the include + // root. Normalize subdirectories and dot segments in both spellings. if (token.startsWith('"')) { - const resolved = path.resolve( - path.dirname(absSource), - token.slice(1, -1), + const quotedPath = token.slice(1, -1); + const packagedPaths = path.posix.isAbsolute(quotedPath) + ? [] + : [ + path.posix.join( + path.posix.dirname(entry.naturalPath), + quotedPath, + ), + path.posix.normalize(quotedPath), + ]; + const packagedPath = packagedPaths.find( + candidate => !candidate.startsWith('../') && entries.has(candidate), ); + if (packagedPath != null) { + entry.includes.internal.push({ + naturalPath: packagedPath, + cxxGuarded: inc.cxxGuarded, + }); + continue; + } + // Preserve the source-to-natural mapping for relocated pod headers + // when neither packaged spelling is present. + const resolved = path.resolve(path.dirname(absSource), quotedPath); const naturals = sourceToNatural.get(resolved); if (naturals && naturals.length > 0) { entry.includes.internal.push({ @@ -613,20 +650,6 @@ function classifyEntries( cxxGuarded: inc.cxxGuarded, }); } else { - const quotedPath = token.slice(1, -1); - const packagedSibling = quotedPath.includes('/') - ? quotedPath - : path.posix.join( - path.posix.dirname(entry.naturalPath), - quotedPath, - ); - if (entries.has(packagedSibling)) { - entry.includes.internal.push({ - naturalPath: packagedSibling, - cxxGuarded: inc.cxxGuarded, - }); - continue; - } // A quoted include in a SHIPPED header that doesn't land on another // shipped header: works in source builds (pod header maps / sibling // files) but has no resolution target in the packaged layout when a diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js index 30a9ce77895..77c34d088e7 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-verify.js +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -495,6 +495,14 @@ function parseArgs(argv /*: Array */) /*: { function main(argv /*:: ?: Array */) /*: void */ { const args = parseArgs(argv ?? process.argv.slice(2)); const inventory = computeInventory(RN_ROOT); + if (inventory.collisions.length > 0) { + const detail = inventory.collisions + .map(c => `${c.naturalPath} <- ${c.sources.join(', ')}`) + .join('\n '); + throw new Error( + `header-inventory natural-path collisions (R8):\n ${detail}`, + ); + } const plan = planFromInventory(inventory, RN_ROOT); if (plan.collisions.length > 0) { throw new Error(`R8 collisions:\n ${plan.collisions.join('\n ')}`);