Skip to content

Commit 2c78f54

Browse files
Tomáš KalinaTomKalina
authored andcommitted
feat(plugin-expo-config-plugins): withRockAutolinking Expo config plugin
Re-applies Rock's autolinking patches on every `expo prebuild` so they survive Expo CNG regenerating `ios/` + `android/` from scratch. Mirrors the one-shot transforms in `packages/create-app/src/lib/utils/initInExistingProject.ts`: - iOS Podfile: rewrite `use_native_modules!(...)` to call Rock's `npx rock config -p ios`. - iOS project.pbxproj: rewrite the React Native build phase shellScript to source Rock's CLI (handles both the Community-CLI default and the RN 0.83 format). - Android app/build.gradle: point `cliFile` at `node_modules/rock/dist/src/bin.js`. - Android settings.gradle: point `autolinkLibrariesFromCommand` at Rock. Composed via three `withDangerousMod` steps (iOS Podfile, iOS Xcode, Android Gradle pair) and exposed as a default-exported `ConfigPlugin` so users can add it once to `app.config.ts` plugins: ```ts plugins: ['@rock-js/plugin-expo-config-plugins/withRockAutolinking'] ``` After that, `expo prebuild --clean` keeps the Rock patches without any manual `npm create rock` re-run. Closes the CNG support gap described in #707. The four patch helpers are pure string-in / string-out and exported for testability — 13 unit tests cover Community-CLI input, Expo prebuild input, idempotency, and the unrelated-content no-op path on each helper.
1 parent 7c966c3 commit 2c78f54

3 files changed

Lines changed: 321 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
11
export * from './lib/pluginExpoConfigPlugins.js';
2+
export {
3+
default as withRockAutolinking,
4+
patchAndroidBuildGradle,
5+
patchAndroidSettingsGradle,
6+
patchPodfile,
7+
patchXcodeProject,
8+
} from './lib/config-plugin/withRockAutolinking.js';
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
patchAndroidBuildGradle,
4+
patchAndroidSettingsGradle,
5+
patchPodfile,
6+
patchXcodeProject,
7+
} from '../withRockAutolinking.js';
8+
9+
describe('patchPodfile', () => {
10+
it('rewrites the Community-CLI bare call', () => {
11+
const input = `target 'App' do
12+
config = use_native_modules!
13+
14+
use_react_native!(:path => config[:reactNativePath])
15+
end
16+
`;
17+
const expected = `target 'App' do
18+
config = use_native_modules!(['npx', 'rock', 'config', '-p', 'ios'])
19+
20+
use_react_native!(:path => config[:reactNativePath])
21+
end
22+
`;
23+
expect(patchPodfile(input)).toBe(expected);
24+
});
25+
26+
it('rewrites the Expo prebuild call with an existing argument', () => {
27+
const input = `target 'App' do
28+
config_command = ['node', '-e', "require('expo/bin/autolinking')"]
29+
config = use_native_modules!(config_command)
30+
end
31+
`;
32+
const expected = `target 'App' do
33+
config_command = ['node', '-e', "require('expo/bin/autolinking')"]
34+
config = use_native_modules!(['npx', 'rock', 'config', '-p', 'ios'])
35+
end
36+
`;
37+
expect(patchPodfile(input)).toBe(expected);
38+
});
39+
40+
it('is idempotent on an already-patched Podfile', () => {
41+
const input = `target 'App' do
42+
config = use_native_modules!(['npx', 'rock', 'config', '-p', 'ios'])
43+
end
44+
`;
45+
expect(patchPodfile(input)).toBe(input);
46+
});
47+
});
48+
49+
describe('patchAndroidBuildGradle', () => {
50+
it('replaces a commented-out cliFile', () => {
51+
const input = `
52+
react {
53+
// cliFile = file("../../node_modules/react-native/cli.js")
54+
}
55+
`;
56+
const expected = `
57+
react {
58+
cliFile = file("../../node_modules/rock/dist/src/bin.js")
59+
}
60+
`;
61+
expect(patchAndroidBuildGradle(input)).toBe(expected);
62+
});
63+
64+
it('replaces a live cliFile pointing at react-native', () => {
65+
const input = `
66+
react {
67+
cliFile = file("\${reactNativeDir}/cli.js")
68+
}
69+
`;
70+
const expected = `
71+
react {
72+
cliFile = file("../../node_modules/rock/dist/src/bin.js")
73+
}
74+
`;
75+
expect(patchAndroidBuildGradle(input)).toBe(expected);
76+
});
77+
78+
it('is idempotent on an already-patched file', () => {
79+
const input = `
80+
react {
81+
cliFile = file("../../node_modules/rock/dist/src/bin.js")
82+
}
83+
`;
84+
expect(patchAndroidBuildGradle(input)).toBe(input);
85+
});
86+
});
87+
88+
describe('patchAndroidSettingsGradle', () => {
89+
it('replaces the full configure block', () => {
90+
const input = `
91+
rootProject.name = 'App'
92+
include ':app'
93+
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand(['node', '-e', "require('@react-native-community/cli').run()"]) }
94+
`;
95+
const out = patchAndroidSettingsGradle(input);
96+
expect(out).toContain("autolinkLibrariesFromCommand(['npx', 'rock', 'config', '-p', 'android'])");
97+
expect(out).not.toContain('@react-native-community/cli');
98+
});
99+
100+
it('falls back to replacing just the inner call', () => {
101+
const input = `someUnrelatedScope { autolinkLibrariesFromCommand(['foo']) }`;
102+
const out = patchAndroidSettingsGradle(input);
103+
expect(out).toContain("autolinkLibrariesFromCommand(['npx', 'rock', 'config', '-p', 'android'])");
104+
});
105+
106+
it('is idempotent', () => {
107+
const input = `autolinkLibrariesFromCommand(['npx', 'rock', 'config', '-p', 'android'])`;
108+
expect(patchAndroidSettingsGradle(input)).toBe(input);
109+
});
110+
});
111+
112+
describe('patchXcodeProject', () => {
113+
it('rewrites the default Community-CLI build phase shellScript', () => {
114+
const original =
115+
'shellScript = "set -e\\n\\nWITH_ENVIRONMENT=\\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\\"\\nREACT_NATIVE_XCODE=\\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\\"\\n\\n/bin/sh -c \\"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\\"\\n";';
116+
const out = patchXcodeProject(original);
117+
expect(out).toContain("require.resolve('rock/package.json')");
118+
expect(out).not.toContain('REACT_NATIVE_XCODE=');
119+
});
120+
121+
it('rewrites the RN 0.83 build phase shellScript', () => {
122+
const original =
123+
'shellScript = "set -e\\n\\nWITH_ENVIRONMENT=\\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\\"\\nREACT_NATIVE_XCODE=\\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\\"\\n\\n/bin/sh -c \\"\\\\\\"$WITH_ENVIRONMENT\\\\\\" \\\\\\"$REACT_NATIVE_XCODE\\\\\\"\\"\\n";';
124+
const out = patchXcodeProject(original);
125+
expect(out).toContain("require.resolve('rock/package.json')");
126+
});
127+
128+
it('is idempotent on an already-patched pbxproj', () => {
129+
const patched =
130+
'shellScript = "set -e\\nif [[ -f \\"$PODS_ROOT/../.xcode.env\\" ]]; then\\nsource \\"$PODS_ROOT/../.xcode.env\\"\\nfi\\nif [[ -f \\"$PODS_ROOT/../.xcode.env.local\\" ]]; then\\nsource \\"$PODS_ROOT/../.xcode.env.local\\"\\nfi\\nexport CONFIG_CMD=\\"dummy-workaround-value\\"\\nexport CLI_PATH=\\"$(\\"$NODE_BINARY\\" --print \\"require(\'path\').dirname(require.resolve(\'rock/package.json\')) + \'/dist/src/bin.js\'\\")\\"\\nWITH_ENVIRONMENT=\\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\\"\\n";';
131+
expect(patchXcodeProject(patched)).toBe(patched);
132+
});
133+
134+
it('leaves unrelated pbxproj content untouched', () => {
135+
const input = `// some random pbxproj content without the target phase`;
136+
expect(patchXcodeProject(input)).toBe(input);
137+
});
138+
});
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Expo Config Plugin that re-applies Rock's autolinking patches on
2+
// every `expo prebuild`. Designed to live in `app.config.ts plugins:
3+
// ['@rock-js/plugin-expo-config-plugins']` so the patches survive
4+
// Expo CNG (Continuous Native Generation) regenerating `ios/` and
5+
// `android/` from scratch.
6+
//
7+
// Mirrors the one-shot transforms in
8+
// `packages/create-app/src/lib/utils/initInExistingProject.ts`:
9+
// - iOS Podfile — point `use_native_modules!` at Rock
10+
// - Android build.gradle — point `cliFile` at Rock's bin.js
11+
// - Android settings.gradle — point `autolinkLibrariesFromCommand`
12+
// at Rock
13+
// - Xcode project.pbxproj — rewrite the React Native build phase
14+
// shellScript to source Rock's CLI
15+
//
16+
// Each step is idempotent so repeated prebuilds don't break.
17+
18+
import { type ConfigPlugin, withDangerousMod } from '@expo/config-plugins';
19+
import * as fs from 'node:fs';
20+
import * as path from 'node:path';
21+
22+
// ----- iOS / Podfile ---------------------------------------------------------
23+
24+
export function patchPodfile(content: string): string {
25+
if (content.includes(`(['npx', 'rock', 'config', '-p', 'ios'])`)) {
26+
// Already patched — leave alone (idempotent).
27+
return content;
28+
}
29+
return content.replace(
30+
/(config\s*=\s*use_native_modules!)(\s*\([^)]*\))?/g,
31+
"$1(['npx', 'rock', 'config', '-p', 'ios'])",
32+
);
33+
}
34+
35+
const withRockIosPodfile: ConfigPlugin = (config) =>
36+
withDangerousMod(config, [
37+
'ios',
38+
async (cfg) => {
39+
const podfilePath = path.join(cfg.modRequest.platformProjectRoot, 'Podfile');
40+
if (!fs.existsSync(podfilePath)) return cfg;
41+
const original = await fs.promises.readFile(podfilePath, 'utf8');
42+
const patched = patchPodfile(original);
43+
if (patched !== original) {
44+
await fs.promises.writeFile(podfilePath, patched);
45+
}
46+
return cfg;
47+
},
48+
]);
49+
50+
// ----- iOS / Xcode project.pbxproj ------------------------------------------
51+
52+
const XCODE_REACT_PHASE_TARGET =
53+
'shellScript = "set -e\\nif [[ -f \\"$PODS_ROOT/../.xcode.env\\" ]]; then\\nsource \\"$PODS_ROOT/../.xcode.env\\"\\nfi\\nif [[ -f \\"$PODS_ROOT/../.xcode.env.local\\" ]]; then\\nsource \\"$PODS_ROOT/../.xcode.env.local\\"\\nfi\\nexport CONFIG_CMD=\\"dummy-workaround-value\\"\\nexport CLI_PATH=\\"$(\\"$NODE_BINARY\\" --print \\"require(\'path\').dirname(require.resolve(\'rock/package.json\')) + \'/dist/src/bin.js\'\\")\\"\\nWITH_ENVIRONMENT=\\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\\"\\n";';
54+
55+
const XCODE_REACT_PHASE_SOURCES = [
56+
// Default Expo / Community-CLI format
57+
'shellScript = "set -e\\n\\nWITH_ENVIRONMENT=\\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\\"\\nREACT_NATIVE_XCODE=\\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\\"\\n\\n/bin/sh -c \\"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\\"\\n";',
58+
// RN 0.83 format
59+
'shellScript = "set -e\\n\\nWITH_ENVIRONMENT=\\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\\"\\nREACT_NATIVE_XCODE=\\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\\"\\n\\n/bin/sh -c \\"\\\\\\"$WITH_ENVIRONMENT\\\\\\" \\\\\\"$REACT_NATIVE_XCODE\\\\\\"\\"\\n";',
60+
];
61+
62+
export function patchXcodeProject(content: string): string {
63+
if (content.includes(XCODE_REACT_PHASE_TARGET)) {
64+
return content;
65+
}
66+
for (const source of XCODE_REACT_PHASE_SOURCES) {
67+
if (content.includes(source)) {
68+
return content.replace(source, XCODE_REACT_PHASE_TARGET);
69+
}
70+
}
71+
return content;
72+
}
73+
74+
const withRockIosXcode: ConfigPlugin = (config) =>
75+
withDangerousMod(config, [
76+
'ios',
77+
async (cfg) => {
78+
const iosDir = cfg.modRequest.platformProjectRoot;
79+
const xcodeProjectFolder = (await fs.promises.readdir(iosDir)).find((f) =>
80+
f.endsWith('.xcodeproj'),
81+
);
82+
if (!xcodeProjectFolder) return cfg;
83+
const projectPath = path.join(iosDir, xcodeProjectFolder, 'project.pbxproj');
84+
if (!fs.existsSync(projectPath)) return cfg;
85+
const original = await fs.promises.readFile(projectPath, 'utf8');
86+
const patched = patchXcodeProject(original);
87+
if (patched !== original) {
88+
await fs.promises.writeFile(projectPath, patched);
89+
}
90+
return cfg;
91+
},
92+
]);
93+
94+
// ----- Android / app/build.gradle -------------------------------------------
95+
96+
const ANDROID_CLI_FILE_TARGET = 'cliFile = file("../../node_modules/rock/dist/src/bin.js")';
97+
98+
export function patchAndroidBuildGradle(content: string): string {
99+
if (content.includes(ANDROID_CLI_FILE_TARGET)) {
100+
return content;
101+
}
102+
return content.replace(
103+
/(?:\/\/\s+)?cliFile\s*=\s*file\([^)]*\)/g,
104+
ANDROID_CLI_FILE_TARGET,
105+
);
106+
}
107+
108+
// ----- Android / settings.gradle --------------------------------------------
109+
110+
const ANDROID_AUTOLINK_TARGET =
111+
"autolinkLibrariesFromCommand(['npx', 'rock', 'config', '-p', 'android'])";
112+
113+
export function patchAndroidSettingsGradle(content: string): string {
114+
if (content.includes(ANDROID_AUTOLINK_TARGET)) {
115+
return content;
116+
}
117+
// Try the full block first (`extensions.configure(...) { ... }`)…
118+
const blockPattern =
119+
/extensions\.configure\(com\.facebook\.react\.ReactSettingsExtension\)\{[^}]*autolinkLibrariesFromCommand\([^)]*\)[^}]*\}/gs;
120+
const blockReplacement = `extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.${ANDROID_AUTOLINK_TARGET} }`;
121+
if (blockPattern.test(content)) {
122+
return content.replace(blockPattern, blockReplacement);
123+
}
124+
// …otherwise fall back to replacing just the inner call.
125+
return content.replace(/autolinkLibrariesFromCommand\([^)]*\)/g, ANDROID_AUTOLINK_TARGET);
126+
}
127+
128+
const withRockAndroid: ConfigPlugin = (config) =>
129+
withDangerousMod(config, [
130+
'android',
131+
async (cfg) => {
132+
const androidDir = cfg.modRequest.platformProjectRoot;
133+
const appBuildGradlePath = path.join(androidDir, 'app', 'build.gradle');
134+
const settingsGradlePath = path.join(androidDir, 'settings.gradle');
135+
136+
if (fs.existsSync(appBuildGradlePath)) {
137+
const original = await fs.promises.readFile(appBuildGradlePath, 'utf8');
138+
const patched = patchAndroidBuildGradle(original);
139+
if (patched !== original) {
140+
await fs.promises.writeFile(appBuildGradlePath, patched);
141+
}
142+
}
143+
if (fs.existsSync(settingsGradlePath)) {
144+
const original = await fs.promises.readFile(settingsGradlePath, 'utf8');
145+
const patched = patchAndroidSettingsGradle(original);
146+
if (patched !== original) {
147+
await fs.promises.writeFile(settingsGradlePath, patched);
148+
}
149+
}
150+
return cfg;
151+
},
152+
]);
153+
154+
// ----- Top-level plugin -----------------------------------------------------
155+
156+
/**
157+
* Expo Config Plugin that re-applies Rock's autolinking patches to the
158+
* native dirs generated by `expo prebuild`. Add it once to
159+
* `app.config.ts` `plugins`:
160+
*
161+
* plugins: [
162+
* '@rock-js/plugin-expo-config-plugins/withRockAutolinking',
163+
* // …
164+
* ],
165+
*
166+
* Idempotent — repeated `expo prebuild --clean` runs converge on the
167+
* same patched files.
168+
*/
169+
export const withRockAutolinking: ConfigPlugin = (config) => {
170+
config = withRockIosPodfile(config);
171+
config = withRockIosXcode(config);
172+
config = withRockAndroid(config);
173+
return config;
174+
};
175+
176+
export default withRockAutolinking;

0 commit comments

Comments
 (0)