Skip to content

Commit 841e98e

Browse files
chrfalchclaude
andcommitted
feat(spm): scaffold cross-package sibling deps (reanimated → worklets)
A scaffolded library that depends on another autolinked library — reanimated `#include <worklets/...>` from react-native-worklets — needs its Package.swift to (a) declare that dependency and (b) have the dependency expose its namespace. Neither happened, so the build failed with `'worklets/Compat/StableApi.h' file not found`. Three gaps fixed: 1. Pod-style dependency names. reanimated's podspec declares `s.dependency "RNWorklets"` — a pod name the `react-native-*` sibling heuristic can't recognize. scaffoldAll now builds a podspec-name → npm-name index (from each autolinked dep's `.podspec` basename) and passes it to translatePodspecToSpmTarget, which maps `RNWorklets` → react-native-worklets and wires it as a sibling .package/.product. 2. Namespace exposure. SPM propagates a target's publicHeadersPath to dependent packages as a search path. Derive publicHeadersPath from the header_mappings_dir namespace root (preferring the cross-platform Common dir), so worklets exposes `Common/cpp` → reanimated resolves `<worklets/...>`. Falls back to the old first-prefix inference when no header_mappings_dir. 3. Sibling path. The autolinker references scaffolded deps via a `libs/<SwiftName>` symlink and SPM resolves relative package paths against that location, so a sibling is at `../<SwiftName>` — not `../<npm-name>` (which resolved to a non-existent `libs/<npm-name>` and failed package resolution). Latent bug, first exercised by a real scaffolded sibling. - spm-types.js: `podspecPath` on AutolinkingIosPlatform. - SCAFFOLDER_VERSION 9 → 11 (output changed → regenerate existing scaffolds). - Tests: pod-name→sibling mapping, self-wire guard, publicHeadersPath from header_mappings_dir, and the corrected sibling path. Verified on a real app: re-scaffolding react-native-reanimated auto-emits the worklets .package/.product + worklets publicHeadersPath "Common/cpp"; the build now resolves packages and compiles past `<worklets/...>`, advancing to the Hermes-header layer (`hermes/hermes.h`, tracked separately) and a worklets version-macro define gap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent edcb7a4 commit 841e98e

3 files changed

Lines changed: 131 additions & 18 deletions

File tree

packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,54 @@ describe('translatePodspecToSpmTarget', () => {
123123
}
124124
});
125125

126+
it('wires a pod-style dependency (RNWorklets) to its npm sibling via the podToNpm index', () => {
127+
const model = podspec({dependencies: ['RNWorklets', 'React-jsi']});
128+
const spec = translatePodspecToSpmTarget(
129+
model,
130+
autolinkedDep({name: 'react-native-reanimated'}),
131+
new Map([
132+
['RNWorklets', 'react-native-worklets'],
133+
['RNReanimated', 'react-native-reanimated'],
134+
]),
135+
);
136+
// RNWorklets → sibling; React-jsi → collapses into ReactNative core.
137+
expect(spec.siblingNames).toContain('react-native-worklets');
138+
expect(spec.coreReactNative).toBe(true);
139+
});
140+
141+
it('does not self-wire when a pod dependency maps back to the dep itself', () => {
142+
const model = podspec({dependencies: ['RNReanimated']});
143+
const spec = translatePodspecToSpmTarget(
144+
model,
145+
autolinkedDep({name: 'react-native-reanimated'}),
146+
new Map([['RNReanimated', 'react-native-reanimated']]),
147+
);
148+
expect(spec.siblingNames).not.toContain('react-native-reanimated');
149+
});
150+
151+
it('derives publicHeadersPath from header_mappings_dir, preferring the cross-platform (Common) namespace root', () => {
152+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wk-scaffold-'));
153+
try {
154+
fs.mkdirSync(path.join(root, 'Common', 'cpp', 'worklets'), {
155+
recursive: true,
156+
});
157+
fs.mkdirSync(path.join(root, 'apple', 'worklets'), {recursive: true});
158+
const model = podspec({
159+
headerMappingsDirs: ['Common/cpp/worklets', 'apple/worklets'],
160+
publicHeaderFiles: ['Common/cpp/worklets/**/*.h'],
161+
});
162+
const spec = translatePodspecToSpmTarget(
163+
model,
164+
autolinkedDep({name: 'react-native-worklets', root}),
165+
);
166+
// Common/cpp (parent of Common/cpp/worklets) is what dependents need to
167+
// resolve <worklets/...>; the apple/ root is not preferred.
168+
expect(spec.publicHeadersPath).toBe('Common/cpp');
169+
} finally {
170+
fs.rmSync(root, {recursive: true, force: true});
171+
}
172+
});
173+
126174
it('does not add "." for a single-segment header_mappings_dir', () => {
127175
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-scaffold-'));
128176
try {
@@ -390,12 +438,15 @@ describe('emitScaffoldedPackageSwift', () => {
390438
expect(out).not.toContain('build/xcframeworks');
391439
});
392440

393-
it('emits sibling .package(path: "../<name>") + .product entries for sibling RN deps', () => {
441+
it('emits sibling .package(path: "../<SwiftName>") + .product entries for sibling RN deps', () => {
394442
const out = emitScaffoldedPackageSwift(
395443
baseSpec({siblingNames: ['react-native-worklets']}),
396444
);
445+
// Path uses the libs/<SwiftName> symlink name (where the autolinker places
446+
// the sibling), NOT the npm name — `../react-native-worklets` would be
447+
// `libs/react-native-worklets`, which does not exist.
397448
expect(out).toContain(
398-
'.package(name: "ReactNativeWorklets", path: "../react-native-worklets")',
449+
'.package(name: "ReactNativeWorklets", path: "../ReactNativeWorklets")',
399450
);
400451
expect(out).toContain(
401452
'.product(name: "ReactNativeWorklets", package: "ReactNativeWorklets")',

packages/react-native/scripts/spm/scaffold-package-swift.js

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,14 @@ const {log} = makeLogger('scaffold-package-swift');
8383
// v8: relative app paths (codegen / xcframeworks) are now computed from the
8484
// autolinker's libs/<SwiftName> symlink location instead of the real dep.root,
8585
// fixing a doubled-path resolution failure on fresh SwiftPM resolves.
86-
const SCAFFOLDER_VERSION = 9;
86+
// v9: header search paths derived from each subspec's header_mappings_dir
87+
// (dirname) so namespaced includes (`<reanimated/...>`) resolve. v10:
88+
// publicHeadersPath derived from the header_mappings_dir namespace root so a
89+
// package exposes `<namespace/...>` to dependents; pod-style sibling deps
90+
// (reanimated's `s.dependency "RNWorklets"`) wired to their npm package. v11:
91+
// sibling .package path uses the libs/<SwiftName> symlink name, not the npm
92+
// name (fixes "package ... doesn't exist" on resolve).
93+
const SCAFFOLDER_VERSION = 11;
8794
const SCAFFOLDER_VERSION_LINE_RE = /^\/\/ AUTO-SCAFFOLDED-VERSION: (\d+)$/m;
8895

8996
const AUTOGEN_MARKER =
@@ -130,6 +137,11 @@ function isReactCoreDep(name /*: string */) /*: boolean */ {
130137
function translatePodspecToSpmTarget(
131138
model /*: PodspecModel */,
132139
dep /*: AutolinkedDep */,
140+
// Maps a podspec name (e.g. "RNWorklets") to the npm package name of an
141+
// autolinked sibling (e.g. "react-native-worklets"). Lets us wire a
142+
// `s.dependency "RNWorklets"` — a pod-style name the `react-native-*`
143+
// heuristic can't recognize — to the right sibling package. Empty by default.
144+
podToNpm /*: Map<string, string> */ = new Map(),
133145
) /*: SpmScaffoldSpec */ {
134146
const warnings = [...model.warnings];
135147

@@ -210,6 +222,7 @@ function translatePodspecToSpmTarget(
210222
if (depName.includes('/') && depName.startsWith(`${selfPodspecName}/`)) {
211223
continue;
212224
}
225+
const podSibling = podToNpm.get(depName.split('/')[0]);
213226
if (isReactCoreDep(depName)) {
214227
coreReactNative = true;
215228
} else if (depName.startsWith('react-native-')) {
@@ -218,6 +231,14 @@ function translatePodspecToSpmTarget(
218231
if (!siblingNames.includes(baseName)) {
219232
siblingNames.push(baseName);
220233
}
234+
} else if (podSibling != null && podSibling !== dep.name) {
235+
// A pod-style dependency name (e.g. reanimated's `s.dependency
236+
// "RNWorklets"`) that resolves to an autolinked sibling's npm package
237+
// (react-native-worklets). Wire it as a sibling .package/.product so
238+
// the dep's cross-package includes (`<worklets/...>`) resolve.
239+
if (!siblingNames.includes(podSibling)) {
240+
siblingNames.push(podSibling);
241+
}
221242
} else {
222243
// Could be a non-RN dep ("MMKV", "AFNetworking"). The scaffolder
223244
// doesn't know how to wire those — surface a warning so user can
@@ -264,15 +285,33 @@ function translatePodspecToSpmTarget(
264285
// instead. publicHeadersPath just has to point at a real dir containing
265286
// some .h files so SPM accepts the target definition.
266287
let publicHeadersPath /*: ?string */ = null;
267-
for (const glob of [...model.publicHeaderFiles, ...model.sourceFiles]) {
268-
const prefix = glob.split('/')[0];
269-
if (
270-
prefix.length > 0 &&
271-
!prefix.includes('*') &&
272-
fs.existsSync(path.join(dep.root, prefix))
273-
) {
274-
publicHeadersPath = prefix;
275-
break;
288+
// Prefer the namespace root (parent of a header_mappings_dir). SPM propagates
289+
// a target's publicHeadersPath to DEPENDENT packages as a search path, so
290+
// setting it to e.g. `Common/cpp` (parent of `Common/cpp/worklets`) is what
291+
// lets a sibling package resolve `<worklets/...>`. Prefer a cross-platform
292+
// (Common) dir — that holds the C++ API siblings consume — over a
293+
// platform-specific (apple/) one.
294+
const mappingsParents = model.headerMappingsDirs
295+
.map(d => path.posix.dirname(d.replace(/^\.\//, '')))
296+
.filter(
297+
p => p.length > 0 && p !== '.' && fs.existsSync(path.join(dep.root, p)),
298+
);
299+
if (mappingsParents.length > 0) {
300+
publicHeadersPath =
301+
mappingsParents.find(p => /(?:^|\/)common(?:\/|$)/i.test(p)) ??
302+
mappingsParents[0];
303+
}
304+
if (publicHeadersPath == null) {
305+
for (const glob of [...model.publicHeaderFiles, ...model.sourceFiles]) {
306+
const prefix = glob.split('/')[0];
307+
if (
308+
prefix.length > 0 &&
309+
!prefix.includes('*') &&
310+
fs.existsSync(path.join(dep.root, prefix))
311+
) {
312+
publicHeadersPath = prefix;
313+
break;
314+
}
276315
}
277316
}
278317
if (publicHeadersPath == null) {
@@ -415,12 +454,13 @@ function emitScaffoldedPackageSwift(
415454
}
416455
for (const siblingName of spec.siblingNames) {
417456
const swiftSibling = toSwiftName(siblingName);
418-
// Sibling deps share this package's node_modules parent dir (the same
419-
// assumption the old runtime siblingPath helper made), so a literal
420-
// relative path covers them — including scoped names, whose `/`
421-
// resolves as a path segment.
457+
// The autolinker references each self-managed (scaffolded) dep through a
458+
// `libs/<SwiftName>` symlink, and SPM resolves a manifest's relative
459+
// package paths against that symlink location — so a sibling lives at
460+
// `../<SwiftName>` (NOT `../<npm-name>`, which would be `libs/<npm-name>`
461+
// and not exist).
422462
packageDeps.push(
423-
`.package(name: "${swiftSibling}", path: "../${siblingName}")`,
463+
`.package(name: "${swiftSibling}", path: "../${swiftSibling}")`,
424464
);
425465
targetDeps.push(
426466
`.product(name: "${swiftSibling}", package: "${swiftSibling}")`,
@@ -508,6 +548,9 @@ type ScaffoldContext = {
508548
// Slot label (e.g. "0.87.0-nightly-20260513-6e262624f/debug") embedded as
509549
// a comment so SPM's manifest hash bumps on slot changes.
510550
cacheSlotLabel: ?string,
551+
// podspec-name → npm-name index over all autolinked deps, so pod-style
552+
// `s.dependency` names (e.g. "RNWorklets") wire to the right sibling.
553+
podToNpm?: Map<string, string>,
511554
};
512555
*/
513556

@@ -658,7 +701,11 @@ function scaffoldPackageSwiftForDep(
658701
};
659702
}
660703

661-
const spec = translatePodspecToSpmTarget(model, dep);
704+
const spec = translatePodspecToSpmTarget(
705+
model,
706+
dep,
707+
ctx.podToNpm ?? new Map(),
708+
);
662709
// Relative paths into the app, embedded in the scaffolded Package.swift.
663710
//
664711
// The manifest is written to <dep.root>/Package.swift, but the autolinker
@@ -799,13 +846,27 @@ function scaffoldAll(
799846
allDeps = directDeps;
800847
}
801848

849+
// Index every autolinked dep's podspec name → its npm name, so a dep that
850+
// depends on a sibling by its pod name (reanimated's `s.dependency
851+
// "RNWorklets"`) can be wired to the sibling's package (react-native-worklets).
852+
// The podspec file basename is the pod name for RN-ecosystem libs (and is
853+
// cheap — no `pod ipc` pre-pass).
854+
const podToNpm /*: Map<string, string> */ = new Map();
855+
for (const dep of allDeps) {
856+
const podspecPath = dep.platforms?.ios?.podspecPath;
857+
if (typeof podspecPath === 'string' && podspecPath.length > 0) {
858+
podToNpm.set(path.basename(podspecPath, '.podspec'), dep.name);
859+
}
860+
}
861+
802862
const ctx /*: ScaffoldContext */ = {
803863
appRoot,
804864
projectRoot,
805865
reactNativeRoot,
806866
force: opts.force === true,
807867
dryRun: opts.dryRun === true,
808868
cacheSlotLabel: opts.cacheSlotLabel ?? null,
869+
podToNpm,
809870
};
810871
const skipSet /*: Set<string> */ = new Set(opts.skipDeps ?? []);
811872

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ export type TargetEntry = {
181181
// ---------------------------------------------------------------------------
182182
export type AutolinkingIosPlatform = {
183183
sourceDir?: ?string,
184+
podspecPath?: ?string,
184185
...
185186
};
186187
// As parsed from autolinking.json — all fields optional because the JSON is

0 commit comments

Comments
 (0)