Skip to content

Commit cdb2a2b

Browse files
chrfalchclaude
andcommitted
fix(spm): SwiftPM Release support — NDEBUG propagation, hermesc, local-artifacts
Makes SwiftPM Release builds link + bundle (Debug already worked). Root-caused via ios-build-matrix.sh; unblocks RN-Tester + community-Fabric SPM Release. - (A) NDEBUG propagation. The Release React.framework strips DebugStringConvertible's vtable (and shifts the ShadowNode layout that inherits it) under NDEBUG, but the autolinked Fabric synth targets compiled with zero RN defines → undefined-symbol / layout-mismatch at link. Emit `.define("DEBUG", .when(.debug))` + `.define("NDEBUG", .when(.release))` on every autolinked C++ target: scaffolded community libs (scaffold-package-swift.js, SCAFFOLDER_VERSION 18→19 so existing scaffolds regenerate), synth wrappers, and inline spm.module targets (generate-spm-autolinking.js). NDEBUG-only is deliberate: Debug is green with no defines, so the always-on RN defines are provably unneeded — NDEBUG is the sole Debug↔Release divergence. Mirrors packages/react-native/Package.swift. - (B) hermesc resolution. Under SPM there is no hermes-engine pod, so react-native-xcode.sh's `$PODS_ROOT/hermes-engine/.../hermesc` fallback resolves to a non-existent path and Release Hermes bundling fails for every SPM app. Inject HERMES_CLI_PATH (=$(REACT_NATIVE_PATH)/../hermes-compiler/ hermesc/osx-bin/hermesc) via mergeReactBuildSettings; react-native-xcode.sh honors a pre-set value before its pod fallback, and an existing user value is left untouched. Reversed byte-for-byte on deinit (covered by the round-trip test). - (Major #3) `--artifacts <local.xcframework>`: artifacts.json now also records ReactNativeHeaders + ReactNativeDependenciesHeaders (they ship beside their binaries in the tarballs). They are required by validateArtifactsCache / generate-spm-package; omitting them made the local slot report incomplete and forced a full Maven download, defeating "use it directly". Tests: NDEBUG defines asserted in the scaffolded + synth manifests; HERMES_CLI_PATH asserted in both build configs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1d7e661 commit cdb2a2b

7 files changed

Lines changed: 90 additions & 13 deletions

packages/react-native/scripts/setup-apple-spm.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -535,8 +535,20 @@ function prepareLocalXcframeworkArtifacts(
535535
{};
536536
artifacts.React = {xcframeworkPath: localReactPath, url: ''};
537537

538-
// Look for ReactNativeDependencies and hermes-engine alongside or in cache
539-
for (const name of ['ReactNativeDependencies', 'hermes-engine']) {
538+
// Look for the deps binary, hermes, AND both headers companions alongside the
539+
// React.xcframework or in the cache. The headers-only companions ship beside
540+
// their binaries in the same tarball (ReactNativeHeaders beside React;
541+
// ReactNativeDependenciesHeaders beside ReactNativeDependencies), so a
542+
// `--artifacts <local React.xcframework>` slot extracted from the tarballs has
543+
// them as siblings. They are REQUIRED (validateArtifactsCache /
544+
// generate-spm-package both need them) — omitting them made the local slot
545+
// report incomplete and forced a full Maven download, defeating "use directly".
546+
for (const name of [
547+
'ReactNativeHeaders',
548+
'ReactNativeDependencies',
549+
'ReactNativeDependenciesHeaders',
550+
'hermes-engine',
551+
]) {
540552
const siblingPath = path.join(localXcfwDir, `${name}.xcframework`);
541553
const cachePath = path.join(
542554
defaultCacheDir(args.version ?? version, args.flavor),

packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,14 @@ describe('generateSynthPackageSwift', () => {
272272
);
273273
});
274274

275+
it('emits DEBUG/NDEBUG config-gated cxxSettings so Fabric C++ matches the prebuilt React.framework ABI', () => {
276+
const result = generateSynthPackageSwift(baseSpec());
277+
expect(result).toContain('.define("DEBUG", .when(configuration: .debug))');
278+
expect(result).toContain(
279+
'.define("NDEBUG", .when(configuration: .release))',
280+
);
281+
});
282+
275283
it('depends on ReactNative via a fixed relative path (default synth depth)', () => {
276284
const result = generateSynthPackageSwift(baseSpec({hasReactDep: true}));
277285
expect(result).toContain(
@@ -1048,7 +1056,10 @@ describe('main() — autolinking plugin host exemption', () => {
10481056
// The plugin-host dep: native sources present, but NO Package.swift.
10491057
const expoDir = path.join(appRoot, 'node_modules', 'expo');
10501058
fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true});
1051-
fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native source\n');
1059+
fs.writeFileSync(
1060+
path.join(expoDir, 'ios', 'Expo.mm'),
1061+
'// native source\n',
1062+
);
10521063
if (withPlugin) {
10531064
fs.writeFileSync(
10541065
path.join(expoDir, 'react-native.config.js'),

packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => {
147147
expect(text.match(/CLANG_CXX_LANGUAGE_STANDARD = "c\+\+20"/g)).toHaveLength(
148148
2,
149149
);
150+
// HERMES_CLI_PATH points react-native-xcode.sh at the hermes-compiler npm
151+
// package (no hermes-engine pod under SPM), injected into both configs.
152+
expect(text.match(/HERMES_CLI_PATH = /g)).toHaveLength(2);
153+
expect(text).toContain(
154+
'$(REACT_NATIVE_PATH)/../hermes-compiler/hermesc/osx-bin/hermesc',
155+
);
150156
});
151157

152158
it('prepends the Sync SPM Autolinking build phase', () => {

packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,12 @@ describe('emitScaffoldedPackageSwift', () => {
525525
expect(out).toContain('// Cache slot: 0.87.0-nightly-20260513-abc/debug');
526526
});
527527

528+
it('emits DEBUG/NDEBUG config-gated cxxSettings so Fabric C++ matches the prebuilt React.framework ABI', () => {
529+
const out = emitScaffoldedPackageSwift(baseSpec());
530+
expect(out).toContain('.define("DEBUG", .when(configuration: .debug))');
531+
expect(out).toContain('.define("NDEBUG", .when(configuration: .release))');
532+
});
533+
528534
it('is fully declarative — no runtime discovery code, no Foundation import', () => {
529535
const out = emitScaffoldedPackageSwift(baseSpec());
530536
expect(out).not.toContain('import Foundation');

packages/react-native/scripts/spm/generate-spm-autolinking.js

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,15 @@ const SKIP_DIRS_DEFAULT /*: ReadonlySet<string> */ = new Set([
336336
// like "<WRAPPER_ROOT_NAME>/Foo.mm" by following this link.
337337
const WRAPPER_ROOT_NAME = 'root';
338338

339+
// The config-gated C++ defines the prebuilt React.framework is built with (see
340+
// packages/react-native/Package.swift). Autolinked Fabric C++ must compile with
341+
// the same NDEBUG state, else DebugStringConvertible's vtable / ShadowNode
342+
// layout diverges from a Release React.framework and the link fails.
343+
const REACT_CXX_CONFIG_DEFINES = [
344+
'.define("DEBUG", .when(configuration: .debug))',
345+
'.define("NDEBUG", .when(configuration: .release))',
346+
];
347+
339348
// Marker the autolinker stamps onto every synth Package.swift it writes.
340349
// Files lacking this marker are treated as user-managed (self-managed) and
341350
// are referenced directly rather than wrapped — see findSelfManagedPackageDir.
@@ -932,6 +941,7 @@ function generateAutolinkedPackageSwift(
932941
name: "${t.name}",
933942
dependencies: [${reactProductDeps()}],
934943
path: "${t.path}",${excludeLine}${publicHeadersLine}${resourcesLine}
944+
cxxSettings: [${REACT_CXX_CONFIG_DEFINES.join(', ')}],
935945
linkerSettings: [.linkedFramework("UIKit"), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")]
936946
)`;
937947
});
@@ -1129,17 +1139,19 @@ function generateSynthPackageSwift(spec /*: SynthPackageSpec */) /*: string */ {
11291139
// `.headerSearchPath(...)` entries from the podspec — first-class
11301140
// directives keep SPM's diagnostics meaningful (clang reports the
11311141
// dep-relative path on miss). React headers need no paths at all.
1132-
const headerSearchPathDirectives = headerSearchPaths
1133-
.map(p => `.headerSearchPath("${p}")`)
1134-
.join(', ');
1142+
const headerSearchPathList = headerSearchPaths.map(
1143+
p => `.headerSearchPath("${p}")`,
1144+
);
11351145
const cSettingsLine =
1136-
headerSearchPaths.length > 0
1137-
? `\n cSettings: [${headerSearchPathDirectives}],`
1138-
: '';
1139-
const cxxSettingsLine =
1140-
headerSearchPaths.length > 0
1141-
? `\n cxxSettings: [${headerSearchPathDirectives}],`
1146+
headerSearchPathList.length > 0
1147+
? `\n cSettings: [${headerSearchPathList.join(', ')}],`
11421148
: '';
1149+
// ALWAYS emit the config-gated C++ defines (even with no header paths): a
1150+
// Fabric C++ target must compile with the same NDEBUG state as the prebuilt
1151+
// React.framework, else DebugStringConvertible's vtable / ShadowNode layout
1152+
// diverges and a Release build fails to link. Mirrors Package.swift.
1153+
const cxxEntries = [...REACT_CXX_CONFIG_DEFINES, ...headerSearchPathList];
1154+
const cxxSettingsLine = `\n cxxSettings: [${cxxEntries.join(', ')}],`;
11431155

11441156
return `// swift-tools-version: 6.0
11451157
// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit manually.

packages/react-native/scripts/spm/generate-spm-xcodeproj.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,18 @@ function mergeReactBuildSettings(
913913
const scalars = [
914914
{key: 'CLANG_CXX_LANGUAGE_STANDARD', value: '"c++20"'},
915915
{key: 'REACT_NATIVE_PATH', value: quoteIfNeeded(reactNativePath)},
916+
// Under SwiftPM there is no hermes-engine pod, so react-native-xcode.sh's
917+
// fallback ($PODS_ROOT/hermes-engine/destroot/bin/hermesc) resolves to a
918+
// non-existent "/hermes-engine/..." and the Release JS→Hermes bundling
919+
// fails. Point HERMES_CLI_PATH at the hermes-compiler npm package's host
920+
// hermesc (sibling of react-native in node_modules). react-native-xcode.sh
921+
// honors an already-set HERMES_CLI_PATH before its pod fallback; the
922+
// ensureScalarField below leaves any user-provided value untouched.
923+
{
924+
key: 'HERMES_CLI_PATH',
925+
value:
926+
'"$(REACT_NATIVE_PATH)/../hermes-compiler/hermesc/osx-bin/hermesc"',
927+
},
916928
];
917929
// Re-locate the buildSettings dict before each edit (offsets shift).
918930
const dict = () => {

packages/react-native/scripts/spm/scaffold-package-swift.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ const {log} = makeLogger('scaffold-package-swift');
9797
// pure-RN; folly/glog/boost/... now come from the deps sidecar);
9898
// publicHeadersPath falls back to "." for root-level-source podspecs
9999
// (`s.source_files = "*.{h,m,mm}"`) instead of SPM's nonexistent `include/`.
100-
const SCAFFOLDER_VERSION = 18;
100+
// v19: scaffolded C++ targets carry DEBUG/NDEBUG config defines so their Fabric
101+
// ABI matches the prebuilt React.framework (Release strips DebugStringConvertible
102+
// under NDEBUG). Bumped so existing scaffolds regenerate with the defines.
103+
const SCAFFOLDER_VERSION = 19;
101104
const SCAFFOLDER_VERSION_LINE_RE = /^\/\/ AUTO-SCAFFOLDED-VERSION: (\d+)$/m;
102105

103106
const AUTOGEN_MARKER =
@@ -110,6 +113,15 @@ const AUTOGEN_MARKER =
110113
// target (cSettings + cxxSettings) to reproduce that ambient import. The
111114
// `__OBJC__` guard makes it inert for plain C/C++ sources, and UIKit is
112115
// `__has_include`-guarded for platforms that lack it.
116+
// The config-gated C++ defines the prebuilt React.framework is built with (see
117+
// packages/react-native/Package.swift). Fabric C++ in autolinked deps must
118+
// compile with the SAME NDEBUG state or its DebugStringConvertible / ShadowNode
119+
// ABI diverges from the Release React.framework and the link fails.
120+
const REACT_CXX_CONFIG_DEFINES = [
121+
'.define("DEBUG", .when(configuration: .debug))',
122+
'.define("NDEBUG", .when(configuration: .release))',
123+
];
124+
113125
const SCAFFOLD_PREFIX_HEADER = 'react-native-spm-prefix.h';
114126
const SCAFFOLD_PREFIX_HEADER_CONTENTS = `// AUTO-SCAFFOLDED by react-native spm scaffold — mirrors CocoaPods' default
115127
// prefix header so ObjC sources that rely on an implicit Foundation/UIKit
@@ -606,6 +618,12 @@ function emitScaffoldedPackageSwift(
606618
...(customFlagsCxx.length > 0
607619
? [`.unsafeFlags([${customFlagsCxx.join(', ')}])`]
608620
: []),
621+
// Match the prebuilt React.framework's config-gated C++ ABI. NDEBUG in
622+
// Release strips DebugStringConvertible's vtable (and shifts the
623+
// ShadowNode layout that inherits it), so a Fabric C++ target compiled
624+
// WITHOUT NDEBUG fails to link against a Release React.framework. Mirrors
625+
// packages/react-native/Package.swift's own C++ targets.
626+
...REACT_CXX_CONFIG_DEFINES,
609627
].filter(e => e.length > 0),
610628
);
611629

0 commit comments

Comments
 (0)