diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md index 3a23c378f91e..c09c909e7f75 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -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 -`/.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 `/.spm-sync-watch-paths`, +and paths that do not exist yet go to `/.spm-sync-watch-absent`. ### `scriptPhases` — build-time shell phases on the app target diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index af1c6e8a463c..bb70910571eb 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -241,7 +241,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. | @@ -605,6 +605,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 diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index a1a92e1fa4d2..ae0369db3d1f 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -1627,7 +1627,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', () => { @@ -1681,8 +1681,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'); @@ -1701,7 +1701,7 @@ 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 ' ],', ' };', @@ -1709,6 +1709,14 @@ describe('main() — .spm-sync-watch-paths emission', () => { ].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( @@ -1733,6 +1741,9 @@ 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); @@ -1740,6 +1751,165 @@ describe('main() — .spm-sync-watch-paths emission', () => { // 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', + ); + }); +}); + +// --------------------------------------------------------------------------- +// main() — libs/ symlinks for self-managed deps +// +// Xcode loads each libs/ symlink as a local package root. Replacing +// one that did not change invalidates the package graph Xcode already holds, +// and the build then fails with "Missing package product". So a sync that +// changes nothing must leave every inode under libs/ — and libs/ itself — +// untouched, while a dep that is gone must lose its symlink. +// --------------------------------------------------------------------------- + +describe('main() — libs/ symlinks for self-managed deps', () => { + let created = []; + let spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + function buildApp(depNames) { + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-libs-sync-')), + ); + 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'}), + ); + + const dependencies = {}; + for (const npmName of depNames) { + // A hand-authored root Package.swift (no AUTOGEN marker) is what makes a + // dep self-managed. + const depDir = path.join(appRoot, 'node_modules', npmName); + fs.mkdirSync(depDir, {recursive: true}); + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version:5.9\n// hand-authored\n', + ); + fs.writeFileSync(path.join(depDir, 'Source.swift'), '// src\n'); + dependencies[npmName] = {root: depDir, platforms: {ios: {}}}; + } + + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + const writeAutolinkingJson = names => + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: Object.fromEntries( + names.map(n => [n, dependencies[n]]), + ), + }), + ); + writeAutolinkingJson(depNames); + + return { + libsDir: path.join(autolinkDir, 'libs'), + writeAutolinkingJson, + sync: () => main(['--app-root', appRoot, '--react-native-root', rnRoot]), + }; + } + + const inodesOf = libsDir => + Object.fromEntries( + ['.', ...fs.readdirSync(libsDir)].map(entry => [ + entry, + fs.lstatSync(path.join(libsDir, entry)).ino, + ]), + ); + + it('keeps every inode when nothing changed', () => { + const app = buildApp(['react-native-foo', 'react-native-bar']); + + app.sync(); + const before = inodesOf(app.libsDir); + expect(Object.keys(before).sort()).toEqual([ + '.', + 'ReactNativeBar', + 'ReactNativeFoo', + ]); + + app.sync(); + expect(inodesOf(app.libsDir)).toEqual(before); + }); + + it('drops the symlink of a dep that is no longer autolinked', () => { + const app = buildApp(['react-native-foo', 'react-native-bar']); + + app.sync(); + expect(fs.readdirSync(app.libsDir).sort()).toEqual([ + 'ReactNativeBar', + 'ReactNativeFoo', + ]); + + app.writeAutolinkingJson(['react-native-foo']); + app.sync(); + expect(fs.readdirSync(app.libsDir)).toEqual(['ReactNativeFoo']); }); }); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index 93a443a5b00f..680c9b0c01e1 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -20,6 +20,7 @@ const { generateXcscheme, readScriptPhasesManifest, } = require('../generate-spm-xcodeproj'); +const {DOMParser} = require('@xmldom/xmldom'); const {execFileSync} = require('child_process'); const fs = require('fs'); const os = require('os'); @@ -137,6 +138,40 @@ describe('scheme pre-action', () => { expect(result).toContain('<'); }); + 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', @@ -184,6 +219,106 @@ describe('sync scripts', () => { ); }); + // Xcode writes per-user scheme state into /.swiftpm/ on every + // IDE build. Counting that as a change made every IDE build re-sync. + describe('the watched-directory staleness probe', () => { + // Runs the generated `find` in isolation, with $P/$STAMP bound as the + // build phase binds them. + function probe(watchedDir, stampFile) { + const findCommand = /\$\((find "\$P"[^()]*)\)/.exec(script)?.[1]; + expect(findCommand).toBeDefined(); + return execFileSync( + '/bin/bash', + [ + '-c', + `set -euo pipefail\nP="$1"\nSTAMP="$2"\n${String(findCommand)}\n`, + 'probe', + watchedDir, + stampFile, + ], + {encoding: 'utf8'}, + ); + } + + let root; + let watchedDir; + let stampFile; + let schemeState; + let source; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-stale-')); + watchedDir = path.join(root, 'node_modules', 'react-native-foo'); + schemeState = path.join( + watchedDir, + '.swiftpm/xcode/xcuserdata/someone.xcuserdatad/xcschemes/xcschememanagement.plist', + ); + source = path.join(watchedDir, 'Foo.swift'); + fs.mkdirSync(path.dirname(schemeState), {recursive: true}); + fs.writeFileSync(schemeState, '\n'); + fs.writeFileSync(source, '// src\n'); + // The stamp is written after the tree, so nothing is newer until a test + // makes it so. + stampFile = path.join(root, '.spm-sync-stamp'); + fs.writeFileSync(stampFile, ''); + }); + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}); + }); + + const touch = file => { + const future = new Date(Date.now() + 10_000); + fs.utimesSync(file, future, future); + }; + + it('ignores Xcode-owned state under .swiftpm', () => { + touch(schemeState); + expect(probe(watchedDir, stampFile)).toBe(''); + }); + + it('still reports a changed source file', () => { + touch(source); + expect(probe(watchedDir, stampFile).trim()).toBe(source); + }); + }); + + 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); diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index f8c4dbdfe2f3..25643e6af954 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -443,6 +443,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); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/autolinking-plugins.js b/packages/react-native/scripts/spm/autolinking-plugins.js index 78eab0be9602..97db445c1f51 100644 --- a/packages/react-native/scripts/spm/autolinking-plugins.js +++ b/packages/react-native/scripts/spm/autolinking-plugins.js @@ -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'], diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index b77decb60562..a62e228ddd27 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -1492,11 +1492,14 @@ function main(argv /*:: ?: Array */) /*: void */ { // is the Swift module name (guaranteed unique per dep), so SPM's // path-basename-based package identity never collides — even when two // libs ship their own Package.swift inside `ios/` (a common convention). - // Wiped on every run; populated below as self-managed deps are visited. + // Populated below as self-managed deps are visited, then pruned. Entries + // that do not change keep their inode: Xcode holds each one as a loaded + // package root, and recreating one it already resolved fails the build with + // "Missing package product". const libsDir = path.join(outputDir, 'libs'); + const wantedLibAliases /*: Set */ = new Set(); fs.mkdirSync(packagesDir, {recursive: true}); fs.mkdirSync(headersDir, {recursive: true}); - fs.rmSync(libsDir, {recursive: true, force: true}); fs.mkdirSync(libsDir, {recursive: true}); const wrapperDirs /*: Map */ = new Map(); @@ -1629,6 +1632,7 @@ function main(argv /*:: ?: Array */) /*: void */ { const realPackageDir = selfManagedDirs.get(target.name) ?? absSource; const aliasPath = path.join(libsDir, target.name); ensureSymlink(aliasPath, realPackageDir); + wantedLibAliases.add(target.name); aggregatorPackageDeps.push({ swiftName: target.name, packagePath: `libs/${target.name}`, @@ -1755,23 +1759,30 @@ function main(argv /*:: ?: Array */) /*: void */ { }); } - // Prune stale wrappers + header dirs for entries no longer autolinked. - // Preserve both wrapper-managed and self-managed names; only entries that - // are no longer autolinked at all get removed. Note: `packages/` only has - // wrapper-managed names (self-managed deps live in their own source dirs), - // but `headers/` has both since we populate the central tree for everyone. + // Prune stale wrappers, header dirs and lib aliases for entries no longer + // autolinked. Preserve both wrapper-managed and self-managed names; only + // entries that are no longer autolinked at all get removed. Note: + // `packages/` only has wrapper-managed names (self-managed deps live in + // their own source dirs), but `headers/` has both since we populate the + // central tree for everyone. `libs/` keeps only the aliases written above, + // so a dep that stopped being self-managed loses its alias too. const activeNames /*: Set */ = new Set([ ...wrapperDirs.keys(), ...selfManagedDirs.keys(), ]); - for (const subdir of ['packages', 'headers']) { + const pruneTargets /*: Array<[string, Set]> */ = [ + ['packages', activeNames], + ['headers', activeNames], + ['libs', wantedLibAliases], + ]; + for (const [subdir, keptNames] of pruneTargets) { const dir = path.join(outputDir, subdir); try { const existing /*: Array<{name: string, isSymbolicLink(): boolean, isDirectory(): boolean}> */ = // $FlowFixMe[incompatible-type] Dirent typing fs.readdirSync(dir, {withFileTypes: true}); for (const entry of existing) { - if (activeNames.has(entry.name)) continue; + if (keptNames.has(entry.name)) continue; const stale = path.join(dir, entry.name); if (entry.isSymbolicLink() || !entry.isDirectory()) { fs.unlinkSync(stale); @@ -1884,12 +1895,15 @@ function main(argv /*:: ?: Array */) /*: 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 */ = [...entryAbsDirs.values()]; for (const entry of entries) { const root = entry.root; @@ -1900,14 +1914,23 @@ function main(argv /*:: ?: Array */) /*: 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 */ = []; + const absentWatchPaths /*: Array */ = []; + 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). diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index fe4687a22101..3c702e20c5bd 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -763,7 +763,9 @@ if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then while IFS= read -r P; do [ -z "$P" ] && continue if [ -d "$P" ]; then - if [ -n "$(find "$P" -newer "$STAMP" -print -quit 2>/dev/null)" ]; then + # .swiftpm holds Xcode's own per-user scheme state, which it rewrites + # during a build — reading it as a change makes every IDE build re-sync. + if [ -n "$(find "$P" -name .swiftpm -prune -o -newer "$STAMP" -print -quit 2>/dev/null)" ]; then STALE=1 break fi @@ -779,6 +781,19 @@ if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then done < "$WATCH_FILE" fi +# Watched paths that did not exist at the last sync. One that APPEARS (e.g. a +# plugin's precompiled output folder) forces a re-sync. +ABSENT_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-absent" +if [ "$STALE" -eq 0 ] && [ -f "$ABSENT_FILE" ]; then + while IFS= read -r P; do + [ -z "$P" ] && continue + if [ -e "$P" ]; then + STALE=1 + break + fi + done < "$ABSENT_FILE" +fi + # Check 2: codegen spec files changed via git (covers monorepo after git pull) if [ "$STALE" -eq 0 ] && [ -f "$STAMP" ]; then STAMP_TIME=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0) @@ -856,28 +871,21 @@ function buildSyncAutolinkingScript( ${syncStaleCheckAndDispatch()} `; } -// XML-attribute escape (the five named entities). The sync script uses `>` -// and `&` for redirection and bg/and chains, plus `<` for heredocs and -// comparisons — all of which break Xcode's scheme parser if left raw. +// XML-attribute escape. The sync script uses `>` and `&` for redirection and +// bg/and chains, plus `<` for heredocs and comparisons — all of which break +// Xcode's scheme parser if left raw. Raw line breaks and tabs are legal but an +// XML parser turns them into spaces (XML 1.0 §3.3.3), collapsing the script +// onto one line, so they are written as character references, as Xcode does. function escapeXmlAttribute(s /*: string */) /*: string */ { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') - .replace(/'/g, '''); -} - -// The inverse of escapeXmlAttribute. `&` is expanded LAST so an entity that -// was itself escaped (`<` → `&lt;`) round-trips back to its own text -// rather than to `<`. -function unescapeXmlAttribute(s /*: string */) /*: string */ { - return s - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&'); + .replace(/'/g, ''') + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\t/g, ' '); } function generateXcscheme( @@ -2531,8 +2539,9 @@ function injectSpmIntoExistingXcodeproj( return {status: 'injected', target: plan.target.name}; } -/** The sync pre-action's script, unescaped, or null when the scheme has none. */ -function schemePreActionScript(xml /*: string */) /*: ?string */ { +// The scheme with the sync pre-action's scriptText emptied, so the compare +// ignores how the script was encoded; null when there is no pre-action. +function withoutPreActionScript(xml /*: string */) /*: ?string */ { const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"'); if (titleIdx < 0) { return null; @@ -2546,9 +2555,7 @@ function schemePreActionScript(xml /*: string */) /*: ?string */ { // escapeXmlAttribute maps a literal `"` to `"`, so the next `"` is always // the closing delimiter. const valueEnd = xml.indexOf('"', valueStart); - return valueEnd < 0 - ? null - : unescapeXmlAttribute(xml.slice(valueStart, valueEnd)); + return valueEnd < 0 ? null : xml.slice(0, valueStart) + xml.slice(valueEnd); } /** @@ -2570,10 +2577,9 @@ function isGeneratedScheme( targetUuid /*: string */, projName /*: string */, ) /*: boolean */ { - const script = schemePreActionScript(xml); return ( - script != null && - xml === generateXcscheme(appName, targetUuid, projName, script) + withoutPreActionScript(xml) === + generateXcscheme(appName, targetUuid, projName, '') ); } diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index 849fecff5637..732309968199 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -314,7 +314,8 @@ export type PluginResult = { flavoredFrameworks: Array, // Absolute paths (dirs or files) the Xcode auto-sync build phase should watch // for staleness, e.g. the plugin dep's own `Package.swift` and per-module - // manifests. Folded into `.spm-sync-watch-paths` by main(). + // manifests. Folded into `.spm-sync-watch-paths` (or `.spm-sync-watch-absent` + // while missing) by main(). watchPaths: Array, // Build-time shell phases for the app target, recorded to // `.spm-plugin-script-phases.json` by main() and injected by `spm add`/