diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6852c4..9cef3c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,33 @@ on: - main jobs: + test-sdk-52: + runs-on: ubuntu-latest + env: + EXPO_SDK_TARGET: 52 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: "zulu" + java-version: "21" + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Install dependencies + run: npm install + + - name: Run integration for SDK 52 + run: ./test/run-integration.sh + test-sdk-51: runs-on: ubuntu-latest env: @@ -15,6 +42,11 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: "zulu" + java-version: "21" + - uses: ruby/setup-ruby@v1 with: ruby-version: "3.3" diff --git a/README.md b/README.md index 5957bc3..7cfedbb 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ To benefit from tree shaking, add the babel plugin to your project's babel confi The `flagsModule` path must match the runtime `mergePath` in your committed flags.yml file. This plugin replaces the `BuildFlags` imports with the literal boolean values which allows the build pipeline to strip unreachable paths. -### Flagged Autolinking +### Flagged Autolinking (for RN >=75) If your feature relies on native module behaviour, you may want to avoid linking that module if the build flag is off. To do so, specify the absolute name or relative path to the module in the base definition for your flag: @@ -73,6 +73,8 @@ modules: branch: some-branch-with-build ``` +In order to enable this you need to pass `flaggedAutolinking: true` as an option to the expo config plugin. + Locally-referenced modules aren't currently supported (until [this 'exclude' exclusion](https://github.com/expo/expo/blob/24d5ae5f288013df19ac09a3406c6a507d781ddb/packages/expo-modules-autolinking/src/autolinking/findModules.ts#L52) can be overridden). ## Goals diff --git a/bin/build-flags-autolinking.sh b/bin/build-flags-autolinking.sh new file mode 100755 index 0000000..6ae990c --- /dev/null +++ b/bin/build-flags-autolinking.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('../build/cli/autolinking.js') diff --git a/package-lock.json b/package-lock.json index 7a1a143..d0a3909 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "yaml": "^2.5.1" }, "bin": { - "build-flags": "bin/build-flags.sh" + "build-flags": "bin/build-flags.sh", + "build-flags-autolinking": "bin/build-flags-autolinking.sh" }, "devDependencies": { "@babel/preset-env": "^7.25.4", diff --git a/package.json b/package.json index 4f25361..7892e8c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "author": "Wes Johnson ", "license": "MIT", "bin": { - "build-flags": "bin/build-flags.sh" + "build-flags": "bin/build-flags.sh", + "build-flags-autolinking": "bin/build-flags-autolinking.sh" }, "main": "build/api/index.js", "files": [ @@ -38,6 +39,7 @@ "scripts": { "build": "tsc", "test": "npm run test:unit && EXPO_SDK_TARGET=51 ./test/run-integration.sh", + "test:next": "EXPO_SDK_TARGET=52 ./test/run-integration.sh", "test:unit": "./node_modules/.bin/jest" } } diff --git a/src/cli/autolinking.ts b/src/cli/autolinking.ts new file mode 100644 index 0000000..b1dd191 --- /dev/null +++ b/src/cli/autolinking.ts @@ -0,0 +1,94 @@ +const commandArgs = process.argv.slice(2); + +const expoAutolinking = require("expo-modules-autolinking"); + +const logMethod = console.log; +const logCallArgs: any[] = []; +console.log = (...args) => { + logCallArgs.push(args); +}; + +const findConfigFromArgs = (args: string[][]) => { + const match = args.find( + (arg) => + Array.isArray(arg) && + typeof arg[0] === "string" && + arg[0][0] === "{" && + arg[0][arg[0].length - 1] === "}" + ); + if (!match) { + throw new Error( + "expo-build-flags autolinking CLI: No match for expected react-native-config object in stdout" + ); + } + + try { + return JSON.parse(match[0]); + } catch (e: any) { + throw new Error( + `expo-build-flags autolinking CLI: Failed to parse react-native-config JSON from stdout: ${e.message}` + ); + } +}; + +type ConfigOutput = { + root: string; + reactNativePath: string; + project: { + ios?: { sourceDir: string }; + android?: { sourceDir: string }; + }; + dependencies: Record; +}; + +const getPlatform = () => { + const platformIndex = commandArgs.indexOf("-p"); + if (platformIndex === -1) { + throw new Error( + "expo-build-flags autolinking CLI: No platform (-p) argument provided" + ); + } + + const platform = commandArgs[platformIndex + 1]; + if (platform !== "ios" && platform !== "android") { + throw new Error( + `expo-build-flags autolinking CLI: Invalid platform provided "${platform}"` + ); + } + + return platform; +}; + +const getExclusions = () => { + return commandArgs.reduce((acc, arg, idx) => { + const next = commandArgs[idx + 1]; + if (arg === "-x" && typeof next === "string") { + return acc.concat(next); + } + return acc; + }, [] as string[]); +}; + +const processConfig = (config: ConfigOutput) => { + const excludedDependencies = getExclusions(); + const updatedConfig = { + ...config, + dependencies: Object.fromEntries( + Object.entries(config.dependencies).filter(([key]) => { + return !excludedDependencies.includes(key); + }) + ), + }; + + return JSON.stringify(updatedConfig); +}; + +expoAutolinking([ + "react-native-config", + "--json", + "--platform", + getPlatform(), +]).then(() => { + console.log = logMethod; + console.log(processConfig(findConfigFromArgs(logCallArgs))); +}); diff --git a/src/config-plugin/withFlaggedAutolinking.spec.ts b/src/config-plugin/withFlaggedAutolinking.spec.ts index 3efa358..da20c93 100644 --- a/src/config-plugin/withFlaggedAutolinking.spec.ts +++ b/src/config-plugin/withFlaggedAutolinking.spec.ts @@ -1,21 +1,26 @@ import fs from "fs"; import { jest, describe, it, expect, beforeEach } from "@jest/globals"; import { - updatePodfileAutolinkCall, - updateGradleAutolinkCall, + updateGradleReactNativeAutolinkCall, + updateGradleExpoModulesAutolinkCall, + updatePodfileReactNativeAutolinkCallForSDK51, + updatePodfileExpoModulesAutolinkCall, } from "./withFlaggedAutolinking"; describe("withFlaggedAutolinking", () => { - describe("updatePodfileAutolinkCall", () => { + describe("updatePodfileExpoModulesAutolinkCall", () => { const podfileContents = fs.readFileSync( "src/config-plugin/fixtures/Podfile", "utf8" ); it("should replace use_expo_modules! with exclude options in call", () => { - const updatedContents = updatePodfileAutolinkCall(podfileContents, { - exclude: ["react-native-device-info"], - }); + const updatedContents = updatePodfileExpoModulesAutolinkCall( + podfileContents, + { + exclude: ["react-native-device-info"], + } + ); const updatedLine = updatedContents .split("\n") .find((line) => @@ -30,16 +35,56 @@ describe("withFlaggedAutolinking", () => { }); }); - describe("updateGradleAutolinkCall", () => { + describe("updateGradleReactNativeAutolinkCall", () => { + const gradleSettingsContents = fs.readFileSync( + "src/config-plugin/fixtures/settings.gradle", + "utf8" + ); + + it("should override config command before passing to RN autolinking call", () => { + const updatedContents = updateGradleReactNativeAutolinkCall( + gradleSettingsContents, + { + exclude: ["react-native-device-info", "some-other-module"], + } + ); + const updatedLineIndex = updatedContents + .split("\n") + .findIndex((line) => + line.trim().startsWith("// expo-build-flags autolinking override") + ); + + expect(updatedLineIndex).toBeGreaterThan(-1); + + const snapshotLineOffset = updatedLineIndex + 1; + const linesToSnapshot = 5; + const matchLines = updatedContents + .split("\n") + .slice(snapshotLineOffset, snapshotLineOffset + linesToSnapshot) + .join("\n"); + expect(matchLines).toMatchInlineSnapshot(` +" command = [ + './node_modules/.bin/build-flags-autolinking', + '-p', 'android', + '-x', 'react-native-device-info', '-x', 'some-other-module' + ].toList()" +`); + }); + }); + + describe("updateGradleExpoModulesAutolinkCall", () => { const gradleSettingsContents = fs.readFileSync( "src/config-plugin/fixtures/settings.gradle", "utf8" ); it("should replace useExpoModules() with exclude options in call", () => { - const updatedContents = updateGradleAutolinkCall(gradleSettingsContents, { - exclude: ["react-native-device-info", "some-other-module"], - }); + const updatedContents = updateGradleExpoModulesAutolinkCall( + gradleSettingsContents, + { + exclude: ["react-native-device-info", "some-other-module"], + } + ); const lines = updatedContents.split("\n"); const updatedLine = lines.find((line) => line.trim().startsWith("useExpoModules") @@ -50,4 +95,41 @@ describe("withFlaggedAutolinking", () => { ); }); }); + + describe("updatePodfileReactNativeAutolinkCallForSDK51", () => { + const podfileContents = fs.readFileSync( + "src/config-plugin/fixtures/Podfile", + "utf8" + ); + + it("should replace origin_autolinking_method.call(config_command) with custom autolinking command", () => { + const updatedContents = updatePodfileReactNativeAutolinkCallForSDK51( + podfileContents, + { + exclude: ["react-native-device-info", "react-native-reanimated"], + } + ); + const updatedLineIndex = updatedContents + .split("\n") + .findIndex((line) => + line.trim().startsWith("# expo-build-flags autolinking override") + ); + + expect(updatedLineIndex).toBeGreaterThan(-1); + + const snapshotLineOffset = updatedLineIndex + 1; + const linesToSnapshot = 5; + const matchLines = updatedContents + .split("\n") + .slice(snapshotLineOffset, snapshotLineOffset + linesToSnapshot) + .join("\n"); + expect(matchLines).toMatchInlineSnapshot(` +" config_command = [ + '../node_modules/.bin/build-flags-autolinking', + '-p', 'ios', + '-x', 'react-native-device-info', '-x', 'react-native-reanimated' + ]" +`); + }); + }); }); diff --git a/src/config-plugin/withFlaggedAutolinking.ts b/src/config-plugin/withFlaggedAutolinking.ts index fda1b20..24c5abf 100644 --- a/src/config-plugin/withFlaggedAutolinking.ts +++ b/src/config-plugin/withFlaggedAutolinking.ts @@ -3,11 +3,25 @@ import path from "path"; import { ConfigPlugin, withDangerousMod } from "@expo/config-plugins"; import { readConfigModuleExclusions } from "../api/readConfig"; -type Props = { flags: string[] }; +type Props = { flags: string[]; expoMajorVersion: number }; + +type Updater = (contents: string, { exclude }: { exclude: string[] }) => string; + +const appleRNLinkingLookup: Record = { + 51: updatePodfileReactNativeAutolinkCallForSDK51, + 52: updatePodfileReactNativeAutolinkCallForSDK52, + default: updatePodfileReactNativeAutolinkCallForSDK52, +}; + +const appleExpoLinkingLookup: Record = { + 51: updatePodfileExpoModulesAutolinkCall, + 52: updatePodfileExpoModulesAutolinkCall, + default: updatePodfileExpoModulesAutolinkCall, +}; const withFlaggedAutolinkingForApple: ConfigPlugin = ( config, - { flags } + { flags, expoMajorVersion } ) => { return withDangerousMod(config, [ "ios", @@ -18,7 +32,18 @@ const withFlaggedAutolinkingForApple: ConfigPlugin = ( ); let contents = await fs.promises.readFile(podfile, "utf8"); const exclude = await getExclusions(flags); - contents = updatePodfileAutolinkCall(contents, { exclude }); + if (!exclude.length) { + return config; + } + + const setupRNModuleLinking = + appleRNLinkingLookup[expoMajorVersion] || appleRNLinkingLookup.default; + const setupExpoModuleLinking = + appleExpoLinkingLookup[expoMajorVersion] || + appleExpoLinkingLookup.default; + + contents = setupRNModuleLinking(contents, { exclude }); + contents = setupExpoModuleLinking(contents, { exclude }); await fs.promises.writeFile(podfile, contents, "utf8"); return config; }, @@ -38,7 +63,11 @@ const withFlaggedAutolinkingForAndroid: ConfigPlugin = ( ); let contents = await fs.promises.readFile(gradleSettings, "utf8"); const exclude = await getExclusions(flags); - contents = updateGradleAutolinkCall(contents, { exclude }); + if (!exclude.length) { + return config; + } + contents = updateGradleReactNativeAutolinkCall(contents, { exclude }); + contents = updateGradleExpoModulesAutolinkCall(contents, { exclude }); await fs.promises.writeFile(gradleSettings, contents, "utf8"); return config; }, @@ -49,13 +78,60 @@ export const withFlaggedAutolinking: ConfigPlugin<{ flags: string[] }> = ( config, props ) => { + const expoPkg = require("expo/package.json"); + const [expoMajorVersion] = expoPkg.version.split("."); + const extendedProps = { + ...props, + expoMajorVersion: parseInt(expoMajorVersion, 10), + }; + + console.log("withFlaggedAutolinking", extendedProps); + return withFlaggedAutolinkingForAndroid( - withFlaggedAutolinkingForApple(config, props), - props + withFlaggedAutolinkingForApple(config, extendedProps), + extendedProps ); }; -export function updatePodfileAutolinkCall( +export function updatePodfileReactNativeAutolinkCallForSDK51( + contents: string, + { exclude }: { exclude: string[] } +): string { + const matchPoint = "origin_autolinking_method.call(config_command)"; + return contents.replace( + matchPoint, + ` + # expo-build-flags autolinking override + config_command = [ + '../node_modules/.bin/build-flags-autolinking', + '-p', 'ios', + ${exclude.map((dep) => [`'-x'`, `'${dep}'`].join(", ")).join(", ")} + ] + ${matchPoint} +` + ); +} + +export function updatePodfileReactNativeAutolinkCallForSDK52( + contents: string, + { exclude }: { exclude: string[] } +): string { + const matchPoint = "config = use_native_modules!(config_command)"; + return contents.replace( + matchPoint, + ` + # expo-build-flags autolinking override + config_command = [ + '../node_modules/.bin/build-flags-autolinking', + '-p', 'ios', + ${exclude.map((dep) => [`'-x'`, `'${dep}'`].join(", ")).join(", ")} + ] + ${matchPoint} +` + ); +} + +export function updatePodfileExpoModulesAutolinkCall( contents: string, { exclude }: { exclude: string[] } ): string { @@ -76,7 +152,26 @@ export function updatePodfileAutolinkCall( ); } -export function updateGradleAutolinkCall( +export function updateGradleReactNativeAutolinkCall( + contents: string, + { exclude }: { exclude: string[] } +): string { + const matchPoint = "ex.autolinkLibrariesFromCommand(command)"; + + return contents.replace( + matchPoint, + `// expo-build-flags autolinking override + command = [ + './node_modules/.bin/build-flags-autolinking', + '-p', 'android', + ${exclude.map((dep) => [`'-x'`, `'${dep}'`].join(", ")).join(", ")} + ].toList() + ${matchPoint} + ` + ); +} + +export function updateGradleExpoModulesAutolinkCall( contents: string, { exclude }: { exclude: string[] } ): string { diff --git a/test/setup.sh b/test/setup.sh index 9aee395..124927a 100755 --- a/test/setup.sh +++ b/test/setup.sh @@ -16,6 +16,12 @@ CI=1 npx create-expo-app --template "expo-template-default@$EXPO_SDK_TARGET" exa echo "Installing library" cd example + +if [ "$EXPO_SDK_TARGET" -eq "51" ]; then + echo "Using expo SDK 51 with react-native 0.75.0" + npm install --save react-native@~0.75.0 +fi + npm install --install-links --save-dev ../ echo "copy over flag fixture" diff --git a/test/test-autolinking.js b/test/test-autolinking.js index 785e915..75894e4 100644 --- a/test/test-autolinking.js +++ b/test/test-autolinking.js @@ -30,7 +30,8 @@ function addModulesForExclusion() { const flagWithModules = ` secretFeature: modules: - expo-splash-screen - - expo-status-bar`; + - expo-status-bar + - react-native-reanimated`; defaultFlags = defaultFlags.replace(" secretFeature:", flagWithModules); console.log("patched flags.yml:\n\n", defaultFlags); fs.writeFileSync("flags.yml", defaultFlags); @@ -38,7 +39,9 @@ function addModulesForExclusion() { async function runAsync() { await runPrebuild(); + await logEnv(); await assertPodfileLockExcludesModules(); + await assertGradleProjectExcludesModules(); process.exit(0); } @@ -58,14 +61,77 @@ async function runPrebuild() { cp.execSync("../node_modules/.bin/pod-lockfile --debug --project ios", { stdio: "inherit", + env: { + ...process.env, + EXPO_UNSTABLE_CORE_AUTOLINKING: "1", + }, }); } function assertPodfileLockExcludesModules() { const podfileLock = fs.readFileSync("ios/Podfile.lock", "utf-8"); + + // assertion for core linking exclusion + if (podfileLock.includes("Reanimated")) { + throw new Error( + "Expected ios/Podfile.lock to exclude react-native-reanimated" + ); + } + + // assertion for expo linking exclusion if (podfileLock.includes("Splash")) { throw new Error("Expected ios/Podfile.lock to exclude expo-splash-screen"); } - console.log("Test passed!"); + console.log("assertPodfileLockExcludesModules passed!"); +} + +function assertGradleProjectExcludesModules() { + return new Promise((resolve, reject) => { + cp.exec( + "cd android && ./gradlew projects --console=plain", + { + stdio: "inherit", + env: { + ...process.env, + EXPO_UNSTABLE_CORE_AUTOLINKING: "1", + }, + }, + (error, stdout, stderr) => { + console.log(stdout); + + if (!stdout.includes("+--- Project ':app'")) { + reject( + new Error("Expected android project to include base project ':app'") + ); + return; + } + + if (stdout.includes("+--- Project ':react-native-reanimated'")) { + reject( + new Error( + "Expected android project to exclude react-native-reanimated" + ) + ); + return; + } + + console.log("assertGradleProjectExcludesModules passed!"); + resolve(); + } + ); + }); +} + +function logEnv() { + return new Promise((resolve) => { + cp.exec( + "cd android && ./gradlew -v", + { stdio: "inherit" }, + (error, stdout, stderr) => { + console.log(stdout); + resolve(); + } + ); + }); }