Skip to content

Commit 6da65e8

Browse files
chrfalcht
authored andcommitted
Re-sync SwiftPM autolinking when a watched path appears (#58828)
Summary: The SwiftPM build-time sync runs only when its stamp is older than one of the files it watches. A watched path that did not exist at the last sync was dropped from the watch list. So when that path appeared later, the sync did not run. Examples: - A plugin watches its precompiled-module folder. On a fresh clone, that folder does not exist yet, so later precompiles never trigger a sync. - A dependency has no root `Package.swift` or `.react-native/` folder. When one appears later, for example after `npx react-native spm scaffold`, the dependency is detected differently, but the sync does not run. This PR writes missing watch paths to a new file, `.spm-sync-watch-absent`. The stamp is stale when one of those paths now exists. The missing paths do not go to `.spm-sync-watch-paths`, because projects from older versions treat a missing path in that file as stale and would sync on every build. Those projects get this fix after running `npx react-native spm update` once. The paths that can go to `.spm-sync-watch-absent`: - a plugin `watchPaths` entry that does not exist yet - the root `Package.swift` and `.react-native/` of each dependency, when they do not exist The docs now tell plugins to list their precompiled-module folder in `watchPaths`, but only a folder that the Xcode build does not write to. This PR does not watch `project.pbxproj`. Xcode rewrites that file on many edits in the IDE, so watching it would run a full sync after each edit. #58827 makes `.spm-injected.json` a watched file instead. Only `spm add` and `spm update` change that file. This PR is independent of #58827. They can land in either order. Both change the generated sync check, so the second one to land needs a rebase. ## Changelog: [IOS] [FIXED] - SwiftPM: the build-time sync now runs again when a watched path that did not exist at the last sync appears. Pull Request resolved: #58828 Test Plan: ``` yarn jest --no-cache -i packages/react-native/scripts/spm/__tests__/ ``` Result: 21 suites and 1010 tests pass. - `generate-spm-autolinking-test.js`: - A missing watch path goes to `.spm-sync-watch-absent` only. This test failed before the change. - A dependency without a root `Package.swift` or `.react-native/` lists both paths as absent. The first version of this PR already did this, so the test passed at once. Removing the two paths from the code makes it fail. - `project.pbxproj` is not watched. This test failed before the change. - `generate-spm-xcodeproj-test.js`: runs the shipped bash check under `/bin/bash`. The stamp is stale only when a path listed as absent now exists. Reviewed By: andrewdacenko Differential Revision: D125206241 Pulled By: cipolleschi fbshipit-source-id: 2cb530615941b15adb27f8fcc4699812538e9f3d (cherry picked from commit c89f412) (cherry picked from commit 8c5d387)
1 parent 90ba117 commit 6da65e8

8 files changed

Lines changed: 149 additions & 15 deletions

File tree

‎packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,18 @@ manifests. On the next build the phase re-syncs when a watched **file** is newer
144144
than the last sync, a watched **dir** has a newer child, or a watched path has
145145
**vanished** (a rename forces a re-sync so the config error surfaces).
146146

147+
A watched path that does not exist yet is watched for **appearing**. When it is
148+
created, the next build re-syncs. So a plugin that builds precompiled modules
149+
should list their output folder, even when a fresh clone does not have it yet.
150+
List only folders that the Xcode build itself does not write to, or every build
151+
re-syncs.
152+
147153
Unlike `flavoredFrameworks`, watch paths are best-effort: a non-array is ignored
148154
with a warning (never fatal), and each non-string / empty / **relative** entry
149155
is dropped with a warning. Absolute-only, because the generated phase tests
150-
these paths with no cwd context. The kept paths are folded into
151-
`<outputDir>/.spm-sync-watch-paths` alongside RN's own, then deduped and sorted.
156+
these paths with no cwd context. The kept paths are folded in with RN's own,
157+
deduped, and sorted: paths that exist go to `<outputDir>/.spm-sync-watch-paths`,
158+
and paths that do not exist yet go to `<outputDir>/.spm-sync-watch-absent`.
152159

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

‎packages/react-native/scripts/spm/__docs__/spm-scripts.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ Because `deinit` drops the marker, it drops both.
258258
| Path | Written by | Contents |
259259
| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
260260
| `build/generated/ios/` | `add`, `update`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). |
261-
| `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. |
261+
| `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. |
262262
| `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`. |
263263
| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. |
264264

@@ -711,6 +711,11 @@ _existing_ set of generated packages current; they do not create the first one.
711711
[plugin](./spm-autolinking-plugins.md#watchpaths--plugin-staleness-inputs)
712712
`watchPaths`; a watched file that is newer, a watched dir with a newer
713713
child, or a watched path that has **vanished** all mark stale
714+
- every path in `.spm-sync-watch-absent` — watched paths that did not exist
715+
at the last sync; one that **appears** marks stale. This includes each
716+
dependency's root `Package.swift` and `.react-native/`: one that appears
717+
later (e.g. after `spm scaffold`) changes self-managed detection. Projects
718+
get this check after running `npx react-native spm update` once
714719
2. If any input is newer (or the stamp is missing): runs
715720
`npx react-native spm sync`, which re-executes autolinking + package
716721
generation (downloading artifacts if the cache slot is incomplete) and writes

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

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

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

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

1951+
// (D) The app's project file: Xcode rewrites it on every IDE edit, so
1952+
// watching it would re-sync after each one.
1953+
fs.mkdirSync(path.join(appRoot, 'App.xcodeproj'));
1954+
fs.writeFileSync(
1955+
path.join(appRoot, 'App.xcodeproj', 'project.pbxproj'),
1956+
'\n',
1957+
);
1958+
19511959
const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking');
19521960
fs.mkdirSync(autolinkDir, {recursive: true});
19531961
fs.writeFileSync(
@@ -1972,13 +1980,64 @@ describe('main() — .spm-sync-watch-paths emission', () => {
19721980
// (C) plugin's existing absolute watchPath is kept.
19731981
expect(lines).toContain(path.join(expoDir, 'Package.swift'));
19741982

1983+
// (D) the app's project file is not watched.
1984+
expect(lines.some(l => l.includes('project.pbxproj'))).toBe(false);
1985+
19751986
// Filtered / dropped entries never reach the file.
19761987
expect(lines.some(l => l.includes('MISSING.swift'))).toBe(false);
19771988
expect(lines.some(l => l.includes('rel/manifest'))).toBe(false);
19781989

19791990
// Deduped and sorted (stable, deterministic output).
19801991
expect(new Set(lines).size).toBe(lines.length);
19811992
expect([...lines].sort()).toEqual(lines);
1993+
1994+
expect(
1995+
fs.readFileSync(path.join(autolinkDir, '.spm-sync-watch-absent'), 'utf8'),
1996+
).toBe(path.join(expoDir, 'MISSING.swift') + '\n');
1997+
});
1998+
1999+
it('lists a dep root without Package.swift or .react-native/ as absent', () => {
2000+
const appRoot = fs.realpathSync(
2001+
fs.mkdtempSync(path.join(os.tmpdir(), 'spm-watch-absent-')),
2002+
);
2003+
created.push(appRoot);
2004+
const rnRoot = path.join(appRoot, 'rn');
2005+
fs.mkdirSync(rnRoot, {recursive: true});
2006+
fs.writeFileSync(
2007+
path.join(appRoot, 'package.json'),
2008+
JSON.stringify({name: 'app'}),
2009+
);
2010+
2011+
// Self-managed through ios/Package.swift, so nothing exists at the root.
2012+
const barDir = path.join(appRoot, 'node_modules', 'react-native-bar');
2013+
fs.mkdirSync(path.join(barDir, 'ios'), {recursive: true});
2014+
fs.writeFileSync(
2015+
path.join(barDir, 'ios', 'Package.swift'),
2016+
'// swift-tools-version:5.9\n// hand-authored\n',
2017+
);
2018+
fs.writeFileSync(path.join(barDir, 'ios', 'Bar.swift'), '// src\n');
2019+
2020+
const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking');
2021+
fs.mkdirSync(autolinkDir, {recursive: true});
2022+
fs.writeFileSync(
2023+
path.join(autolinkDir, 'autolinking.json'),
2024+
JSON.stringify({
2025+
dependencies: {
2026+
'react-native-bar': {root: barDir, platforms: {ios: {}}},
2027+
},
2028+
}),
2029+
);
2030+
2031+
main(['--app-root', appRoot, '--react-native-root', rnRoot]);
2032+
2033+
expect(
2034+
fs.readFileSync(path.join(autolinkDir, '.spm-sync-watch-absent'), 'utf8'),
2035+
).toBe(
2036+
[
2037+
path.join(barDir, '.react-native'),
2038+
path.join(barDir, 'Package.swift'),
2039+
].join('\n') + '\n',
2040+
);
19822041
});
19832042
});
19842043

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,42 @@ describe('sync scripts', () => {
333333
});
334334
});
335335

336+
it('re-syncs when a path listed as absent appears', () => {
337+
const loop = /^ABSENT_FILE=[\s\S]*?^fi$/m.exec(script)?.[0];
338+
expect(loop).toBeDefined();
339+
const probe = srcRoot =>
340+
execFileSync(
341+
'/bin/bash',
342+
[
343+
'-c',
344+
`set -euo pipefail\nSRCROOT="$1"\nSTALE=0\n${String(loop)}\necho "$STALE"\n`,
345+
'probe',
346+
srcRoot,
347+
],
348+
{encoding: 'utf8'},
349+
).trim();
350+
351+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-absent-'));
352+
try {
353+
const autolinkDir = path.join(root, 'build/generated/autolinking');
354+
fs.mkdirSync(autolinkDir, {recursive: true});
355+
const watched = path.join(root, 'precompiled');
356+
fs.mkdirSync(watched);
357+
expect(probe(root)).toBe('0');
358+
359+
fs.writeFileSync(
360+
path.join(autolinkDir, '.spm-sync-watch-absent'),
361+
`${watched}\n`,
362+
);
363+
expect(probe(root)).toBe('1');
364+
365+
fs.rmdirSync(watched);
366+
expect(probe(root)).toBe('0');
367+
} finally {
368+
fs.rmSync(root, {recursive: true, force: true});
369+
}
370+
});
371+
336372
it('is deterministic, shared with the pre-action, and valid POSIX shell', () => {
337373
expect(buildSyncAutolinkingScript(baked)).toBe(script);
338374
expect(buildSchemePreActionScript(baked)).toBe(script);

‎packages/react-native/scripts/spm/autolinking-plugins.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@
6565
* // Absolute paths (dirs OR files) the Xcode auto-sync build phase
6666
* // should watch for staleness — e.g. the plugin dep's own
6767
* // `Package.swift`, `expo-module.config.json`, and per-module
68-
* // manifests. Folded into `.spm-sync-watch-paths`; a file edit or a
68+
* // manifests. Folded into `.spm-sync-watch-paths` (or
69+
* // `.spm-sync-watch-absent` while missing); a file edit or a
6970
* // dir add/remove there re-triggers the sync. Relative/empty/non-string
7071
* // entries are dropped with a warning; a non-array is ignored.
7172
* watchPaths: ['/abs/path/Package.swift', '/abs/dir'],

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,12 +1920,15 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
19201920
// stale until an unrelated install triggers a sync. The dep ROOT is
19211921
// threaded through the entry (entry.root, from the autolinking model),
19221922
// not derived by walking up from the (possibly nested) source dir.
1923+
// Both are listed even when absent: one that appears later (e.g. after
1924+
// `spm scaffold`) changes self-managed detection and needs a re-sync.
19231925
// 3. Plugin-contributed paths (e.g. Expo's own Package.swift / per-module
19241926
// manifests) — already validated absolute in invokePlugins.
19251927
// The phase distinguishes dir vs file with `-d`/`-f` at build time (no
1926-
// markers). The existsSync filter here is safe: these paths come from a
1927-
// successful sync so they exist now; a path that later VANISHES is caught at
1928-
// phase time against this file and forces a re-sync.
1928+
// markers). A listed path that later VANISHES forces a re-sync. Paths that do
1929+
// not exist yet go to .spm-sync-watch-absent, where one that APPEARS forces a
1930+
// re-sync; they stay out of this file because phases from older versions
1931+
// treat a missing listed path as stale.
19291932
const watchCandidates /*: Array<string> */ = [...entryAbsDirs.values()];
19301933
for (const entry of entries) {
19311934
const root = entry.root;
@@ -1936,14 +1939,23 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
19361939
watchCandidates.push(path.join(root, '.react-native'));
19371940
}
19381941
watchCandidates.push(...pluginWatchPaths);
1939-
const watchPaths = Array.from(new Set(watchCandidates))
1940-
.filter(p => p.length > 0 && fs.existsSync(p))
1941-
.sort();
1942+
const watchPaths /*: Array<string> */ = [];
1943+
const absentWatchPaths /*: Array<string> */ = [];
1944+
for (const p of Array.from(new Set(watchCandidates)).sort()) {
1945+
if (p.length > 0) {
1946+
(fs.existsSync(p) ? watchPaths : absentWatchPaths).push(p);
1947+
}
1948+
}
19421949
fs.writeFileSync(
19431950
path.join(outputDir, '.spm-sync-watch-paths'),
19441951
watchPaths.join('\n') + (watchPaths.length > 0 ? '\n' : ''),
19451952
'utf8',
19461953
);
1954+
fs.writeFileSync(
1955+
path.join(outputDir, '.spm-sync-watch-absent'),
1956+
absentWatchPaths.join('\n') + (absentWatchPaths.length > 0 ? '\n' : ''),
1957+
'utf8',
1958+
);
19471959

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

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,19 @@ if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then
785785
done < "$WATCH_FILE"
786786
fi
787787
788+
# Watched paths that did not exist at the last sync. One that APPEARS (e.g. a
789+
# plugin's precompiled output folder) forces a re-sync.
790+
ABSENT_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-absent"
791+
if [ "$STALE" -eq 0 ] && [ -f "$ABSENT_FILE" ]; then
792+
while IFS= read -r P; do
793+
[ -z "$P" ] && continue
794+
if [ -e "$P" ]; then
795+
STALE=1
796+
break
797+
fi
798+
done < "$ABSENT_FILE"
799+
fi
800+
788801
# Check 2: codegen spec files changed via git (covers monorepo after git pull)
789802
if [ "$STALE" -eq 0 ] && [ -f "$STAMP" ]; then
790803
STAMP_TIME=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)

‎packages/react-native/scripts/spm/spm-types.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,8 @@ export type PluginResult = {
324324
flavoredFrameworks: Array<PluginFlavoredFramework>,
325325
// Absolute paths (dirs or files) the Xcode auto-sync build phase should watch
326326
// for staleness, e.g. the plugin dep's own `Package.swift` and per-module
327-
// manifests. Folded into `.spm-sync-watch-paths` by main().
327+
// manifests. Folded into `.spm-sync-watch-paths` (or `.spm-sync-watch-absent`
328+
// while missing) by main().
328329
watchPaths: Array<string>,
329330
// Build-time shell phases for the app target, recorded to
330331
// `.spm-plugin-script-phases.json` by main() and injected by `spm add`/

0 commit comments

Comments
 (0)