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 @@ -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. |

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand All @@ -1701,14 +1701,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 @@ -1733,13 +1741,175 @@ 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',
);
});
});

// ---------------------------------------------------------------------------
// main() — libs/ symlinks for self-managed deps
//
// Xcode loads each libs/<SwiftName> 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']);
});
});

Expand Down
Loading
Loading