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
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,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
7 changes: 6 additions & 1 deletion packages/react-native/scripts/spm/__docs__/spm-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ Because `deinit` drops the marker, it drops both.
| Path | Written by | Contents |
| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `build/generated/ios/` | `add`, `update`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). |
| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, and any `.spm-plugin-*.json` plugin manifests. |
| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, `.spm-sync-watch-absent`, and any `.spm-plugin-*.json` manifests. |
| `build/xcframeworks/` | `add`, `update`, `sync`, `download` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. |
| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. |

Expand Down Expand Up @@ -711,6 +711,11 @@ _existing_ set of generated packages current; they do not create the first one.
[plugin](./spm-autolinking-plugins.md#watchpaths--plugin-staleness-inputs)
`watchPaths`; a watched file that is newer, a watched dir with a newer
child, or a watched path that has **vanished** all mark stale
- every path in `.spm-sync-watch-absent` — watched paths that did not exist
at the last sync; one that **appears** marks stale. This includes each
dependency's root `Package.swift` and `.react-native/`: one that appears
later (e.g. after `spm scaffold`) changes self-managed detection. Projects
get this check after running `npx react-native spm update` once
2. If any input is newer (or the stamp is missing): runs
`npx react-native spm sync`, which re-executes autolinking + package
generation (downloading artifacts if the cache slot is incomplete) and writes
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {
generateXcscheme,
readScriptPhasesManifest,
} = require('../generate-spm-xcodeproj');
const {DOMParser} = require('@xmldom/xmldom');
const {execFileSync} = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
Expand Down Expand Up @@ -137,6 +138,40 @@ describe('scheme pre-action', () => {
expect(result).toContain('&lt;');
});

function parsedSyncScript(xml) {
return new DOMParser()
.parseFromString(xml, 'text/xml')
.getElementsByTagName('ActionContent')[0]
.getAttribute('scriptText');
}

const MULTILINE_SCRIPT = 'set -e\n\tif [ "$A" ]; then\r\n a && b < c\nfi';

it('keeps line breaks and tabs when an XML parser reads the script', () => {
const result = generateXcscheme(
'MyApp',
'TARGET_UUID',
'MyApp',
MULTILINE_SCRIPT,
);
expect(parsedSyncScript(result)).toBe(MULTILINE_SCRIPT);
});

it('encodes line breaks when it refreshes a script written with raw ones', () => {
const legacy = generateXcscheme(
'MyApp',
'TARGET_UUID',
'MyApp',
'OLD',
).replace('scriptText = "OLD"', 'scriptText = "old\n# line 2"');
const updated = addPreActionToScheme(
legacy,
'TARGET_UUID',
MULTILINE_SCRIPT,
);
expect(parsedSyncScript(updated)).toBe(MULTILINE_SCRIPT);
});

it('refreshes stale script text and is idempotent', () => {
const first = generateXcscheme(
'MyApp',
Expand Down Expand Up @@ -298,6 +333,42 @@ describe('sync scripts', () => {
});
});

it('re-syncs when a path listed as absent appears', () => {
const loop = /^ABSENT_FILE=[\s\S]*?^fi$/m.exec(script)?.[0];
expect(loop).toBeDefined();
const probe = srcRoot =>
execFileSync(
'/bin/bash',
[
'-c',
`set -euo pipefail\nSRCROOT="$1"\nSTALE=0\n${String(loop)}\necho "$STALE"\n`,
'probe',
srcRoot,
],
{encoding: 'utf8'},
).trim();

const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-absent-'));
try {
const autolinkDir = path.join(root, 'build/generated/autolinking');
fs.mkdirSync(autolinkDir, {recursive: true});
const watched = path.join(root, 'precompiled');
fs.mkdirSync(watched);
expect(probe(root)).toBe('0');

fs.writeFileSync(
path.join(autolinkDir, '.spm-sync-watch-absent'),
`${watched}\n`,
);
expect(probe(root)).toBe('1');

fs.rmdirSync(watched);
expect(probe(root)).toBe('0');
} finally {
fs.rmSync(root, {recursive: true, force: true});
}
});

it('is deterministic, shared with the pre-action, and valid POSIX shell', () => {
expect(buildSyncAutolinkingScript(baked)).toBe(script);
expect(buildSchemePreActionScript(baked)).toBe(script);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,21 @@ describe('deinit — scheme ownership', () => {
expect(deinit().status).toBe('removed');
expect(fs.existsSync(schemePath)).toBe(false);
});

it('deletes its own scheme written with raw line breaks in the script', () => {
const {sync, deinit, schemePath, readScheme} = setUp();
sync();
fs.writeFileSync(
schemePath,
readScheme().replace(/&#(9|10|13);/g, (_, c) =>
String.fromCharCode(Number(c)),
),
'utf8',
);

expect(deinit().status).toBe('removed');
expect(fs.existsSync(schemePath)).toBe(false);
});
});

// ---------------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion packages/react-native/scripts/spm/autolinking-plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
* // Absolute paths (dirs OR files) the Xcode auto-sync build phase
* // should watch for staleness — e.g. the plugin dep's own
* // `Package.swift`, `expo-module.config.json`, and per-module
* // manifests. Folded into `.spm-sync-watch-paths`; a file edit or a
* // manifests. Folded into `.spm-sync-watch-paths` (or
* // `.spm-sync-watch-absent` while missing); a file edit or a
* // dir add/remove there re-triggers the sync. Relative/empty/non-string
* // entries are dropped with a warning; a non-array is ignored.
* watchPaths: ['/abs/path/Package.swift', '/abs/dir'],
Expand Down
24 changes: 18 additions & 6 deletions packages/react-native/scripts/spm/generate-spm-autolinking.js
Original file line number Diff line number Diff line change
Expand Up @@ -1920,12 +1920,15 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
// stale until an unrelated install triggers a sync. The dep ROOT is
// threaded through the entry (entry.root, from the autolinking model),
// not derived by walking up from the (possibly nested) source dir.
// Both are listed even when absent: one that appears later (e.g. after
// `spm scaffold`) changes self-managed detection and needs a re-sync.
// 3. Plugin-contributed paths (e.g. Expo's own Package.swift / per-module
// manifests) — already validated absolute in invokePlugins.
// The phase distinguishes dir vs file with `-d`/`-f` at build time (no
// markers). The existsSync filter here is safe: these paths come from a
// successful sync so they exist now; a path that later VANISHES is caught at
// phase time against this file and forces a re-sync.
// markers). A listed path that later VANISHES forces a re-sync. Paths that do
// not exist yet go to .spm-sync-watch-absent, where one that APPEARS forces a
// re-sync; they stay out of this file because phases from older versions
// treat a missing listed path as stale.
const watchCandidates /*: Array<string> */ = [...entryAbsDirs.values()];
for (const entry of entries) {
const root = entry.root;
Expand All @@ -1936,14 +1939,23 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
watchCandidates.push(path.join(root, '.react-native'));
}
watchCandidates.push(...pluginWatchPaths);
const watchPaths = Array.from(new Set(watchCandidates))
.filter(p => p.length > 0 && fs.existsSync(p))
.sort();
const watchPaths /*: Array<string> */ = [];
const absentWatchPaths /*: Array<string> */ = [];
for (const p of Array.from(new Set(watchCandidates)).sort()) {
if (p.length > 0) {
(fs.existsSync(p) ? watchPaths : absentWatchPaths).push(p);
}
}
fs.writeFileSync(
path.join(outputDir, '.spm-sync-watch-paths'),
watchPaths.join('\n') + (watchPaths.length > 0 ? '\n' : ''),
'utf8',
);
fs.writeFileSync(
path.join(outputDir, '.spm-sync-watch-absent'),
absentWatchPaths.join('\n') + (absentWatchPaths.length > 0 ? '\n' : ''),
'utf8',
);

// AutolinkedAggregate is glue; needs at least one source file (Swift, so we
// sidestep the Obj-C public-headers-dir requirement).
Expand Down
Loading
Loading