Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/community-cli-plugin/src/commands/spm.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ const spmCommand: Command = {
name: 'spm [action]',
description:
'Set up or maintain Swift Package Manager support for the iOS/macOS app. ' +
'Actions: add, update, deinit, scaffold. With no action: add (or update ' +
'if SPM is already set up).',
'Actions: add, update, deinit, scaffold, sync. With no action: add (or ' +
'update if SPM is already set up).',
options: [
{
name: '--version <string>',
Expand Down
15 changes: 10 additions & 5 deletions packages/react-native/scripts/setup-apple-spm.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@
* .spm-injected.json) and drop the marker.
* scaffold Generate Package.swift for community deps that
* lack SPM support.
* sync / codegen / download Advanced/internal: `sync` is invoked by the
* generated Xcode build phase; `codegen` and
* `download` run a single pipeline step.
* sync Re-run the build-time autolinking sync and its
* checks. The generated Xcode build phase runs
* it; run it by hand after precompiling a module.
* codegen / download Advanced/internal: run a single pipeline step.
*
* Zero-arg `npx react-native spm` auto-detects: a freshly-scaffolded CocoaPods
* project (clean tree, stock Podfile) → `add --deintegrate`; an injected
Expand Down Expand Up @@ -93,6 +94,7 @@ const {
const {main: generatePackage} = require('./spm/generate-spm-package');
const {findSourcePath} = require('./spm/generate-spm-package');
const {
PluginFrameworkMismatchError,
SPM_INJECTED_MARKER,
cleanupDanglingJavaScriptCoreRef,
cleanupLeftoverPodsGroup,
Expand Down Expand Up @@ -1200,8 +1202,11 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
iosDeploymentTarget,
]);
} catch (e) {
if (e instanceof MissingManifestError) {
// The per-dep `error:` lines were already printed by the autolinker.
if (
e instanceof MissingManifestError ||
e instanceof PluginFrameworkMismatchError
) {
// The `error:` lines were already printed.
// Exit 2 (distinct from generic failure) so the Xcode build phase can
// turn this into a hard build error while staying lenient on transient
// sync failures.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ The declarations are recorded to
immutable app-local slots as React Native, and added to Xcode's exact linker and
embed settings. They are not emitted as SwiftPM product dependencies. Adding or
removing one requires `spm update`; the build-time `spm sync` intentionally does
not mutate runtime framework settings.
not mutate runtime framework settings. When the frameworks that plugins provide
and the frameworks that the project links disagree, sync fails the build. An
`error:` line names each framework and the command to run.

### `watchPaths` — plugin staleness inputs

Expand All @@ -144,11 +146,18 @@ manifests. On the next build the phase re-syncs when a watched **file** is newer
than the last sync, a watched **dir** has a newer child, or a watched path has
**vanished** (a rename forces a re-sync so the config error surfaces).

A watched path that does not exist yet is watched for **appearing**. When it is
created, the next build re-syncs. So a plugin that builds precompiled modules
should list their output folder, even when a fresh clone does not have it yet.
List only folders that the Xcode build itself does not write to, or every build
re-syncs.

Unlike `flavoredFrameworks`, watch paths are best-effort: a non-array is ignored
with a warning (never fatal), and each non-string / empty / **relative** entry
is dropped with a warning. Absolute-only, because the generated phase tests
these paths with no cwd context. The kept paths are folded into
`<outputDir>/.spm-sync-watch-paths` alongside RN's own, then deduped and sorted.
these paths with no cwd context. The kept paths are folded in with RN's own,
deduped, and sorted: paths that exist go to `<outputDir>/.spm-sync-watch-paths`,
and paths that do not exist yet go to `<outputDir>/.spm-sync-watch-absent`.

### `scriptPhases` — build-time shell phases on the app target

Expand Down
69 changes: 46 additions & 23 deletions packages/react-native/scripts/spm/__docs__/spm-scripts.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -1879,7 +1879,7 @@ describe('main() — scriptPhases sidecar', () => {
// and deduped/sorted: (1) each module's source DIR; (2) each npm dep's
// checked-in root Package.swift (FILE) and .react-native/ (DIR) — threaded from
// the autolinking model's dep root, not derived by walking up; (3) plugin
// watchPaths. Nonexistent paths are filtered at emission time.
// watchPaths. Nonexistent paths go to .spm-sync-watch-absent instead.
// ---------------------------------------------------------------------------

describe('main() — .spm-sync-watch-paths emission', () => {
Expand Down Expand Up @@ -1920,8 +1920,8 @@ describe('main() — .spm-sync-watch-paths emission', () => {
fs.writeFileSync(path.join(fooDir, 'Foo.swift'), '// src\n');

// (C) A plugin-host dep contributing watchPaths: one existing absolute path
// (kept), one absent absolute path (filtered at emission), one relative
// path (dropped by invokePlugins).
// (kept), one absent absolute path (listed as absent), one relative path
// (dropped by invokePlugins).
const expoDir = path.join(appRoot, 'node_modules', 'expo');
fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true});
fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\n');
Expand All @@ -1940,14 +1940,22 @@ describe('main() — .spm-sync-watch-paths emission', () => {
" productDependencies: [{name: 'ExpoModulesCore', package: 'ExpoModulesCore'}],",
' watchPaths: [',
" path.join(__dirname, 'Package.swift'),", // exists → kept
" path.join(__dirname, 'MISSING.swift'),", // absent → filtered at emission
" path.join(__dirname, 'MISSING.swift'),", // absent → listed as absent
" 'rel/manifest',", // relative → dropped by invokePlugins
' ],',
' };',
'};',
].join('\n') + '\n',
);

// (D) The app's project file: Xcode rewrites it on every IDE edit, so
// watching it would re-sync after each one.
fs.mkdirSync(path.join(appRoot, 'App.xcodeproj'));
fs.writeFileSync(
path.join(appRoot, 'App.xcodeproj', 'project.pbxproj'),
'\n',
);

const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking');
fs.mkdirSync(autolinkDir, {recursive: true});
fs.writeFileSync(
Expand All @@ -1972,13 +1980,64 @@ describe('main() — .spm-sync-watch-paths emission', () => {
// (C) plugin's existing absolute watchPath is kept.
expect(lines).toContain(path.join(expoDir, 'Package.swift'));

// (D) the app's project file is not watched.
expect(lines.some(l => l.includes('project.pbxproj'))).toBe(false);

// Filtered / dropped entries never reach the file.
expect(lines.some(l => l.includes('MISSING.swift'))).toBe(false);
expect(lines.some(l => l.includes('rel/manifest'))).toBe(false);

// Deduped and sorted (stable, deterministic output).
expect(new Set(lines).size).toBe(lines.length);
expect([...lines].sort()).toEqual(lines);

expect(
fs.readFileSync(path.join(autolinkDir, '.spm-sync-watch-absent'), 'utf8'),
).toBe(path.join(expoDir, 'MISSING.swift') + '\n');
});

it('lists a dep root without Package.swift or .react-native/ as absent', () => {
const appRoot = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'spm-watch-absent-')),
);
created.push(appRoot);
const rnRoot = path.join(appRoot, 'rn');
fs.mkdirSync(rnRoot, {recursive: true});
fs.writeFileSync(
path.join(appRoot, 'package.json'),
JSON.stringify({name: 'app'}),
);

// Self-managed through ios/Package.swift, so nothing exists at the root.
const barDir = path.join(appRoot, 'node_modules', 'react-native-bar');
fs.mkdirSync(path.join(barDir, 'ios'), {recursive: true});
fs.writeFileSync(
path.join(barDir, 'ios', 'Package.swift'),
'// swift-tools-version:5.9\n// hand-authored\n',
);
fs.writeFileSync(path.join(barDir, 'ios', 'Bar.swift'), '// src\n');

const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking');
fs.mkdirSync(autolinkDir, {recursive: true});
fs.writeFileSync(
path.join(autolinkDir, 'autolinking.json'),
JSON.stringify({
dependencies: {
'react-native-bar': {root: barDir, platforms: {ios: {}}},
},
}),
);

main(['--app-root', appRoot, '--react-native-root', rnRoot]);

expect(
fs.readFileSync(path.join(autolinkDir, '.spm-sync-watch-absent'), 'utf8'),
).toBe(
[
path.join(barDir, '.react-native'),
path.join(barDir, 'Package.swift'),
].join('\n') + '\n',
);
});
});

Expand Down
Loading
Loading