Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,7 @@ packages/react-native-brownfield/ios/swiftpm/.build/
.skillgym-results/

.cursor

# Brownfield
**/.brownfield/
**/.brownfield_navigation_override/
18 changes: 9 additions & 9 deletions apps/RNApp/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PODS:
- BrownfieldNavigation (3.8.1):
- BrownfieldNavigation (3.10.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand All @@ -21,7 +21,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- Brownie (3.8.1):
- Brownie (3.10.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -1803,7 +1803,7 @@ PODS:
- ReactNativeDependencies
- ReactAppDependencyProvider (0.85.0):
- ReactCodegen
- ReactBrownfield (3.8.1):
- ReactBrownfield (3.10.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -2174,10 +2174,10 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"

SPEC CHECKSUMS:
BrownfieldNavigation: 6bab41701e536267009b77cf6dc6a8a509db1f4a
Brownie: c0b61f2fd916a0bcb6b4918b49aa3eebec5e1cd6
BrownfieldNavigation: 7dd35f5096bf15980e49152cadd2c6ca0137f13a
Brownie: bb49a5d914b1e728617f7a845644a6d3a048dec4
FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b
hermes-engine: 891a8d77b6705a5c71992a8f3eaf2bfc2cfb1dda
hermes-engine: 133acc7688f66a6db232bff7de874c7129b01e1e
RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257
RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64
RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a
Expand All @@ -2186,7 +2186,7 @@ SPEC CHECKSUMS:
React: 1b1536b9099195944034e65b1830f463caaa8390
React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5
React-Core: 00faa4d038298089a1d5a5b21dde8660c4f0820d
React-Core-prebuilt: ca4f822f6813ee31e20a965602ed5bbe611a003d
React-Core-prebuilt: 46fad9d85d53619a69cbd91fa8703ce085c73c29
React-CoreModules: a17807f849bfd86045b0b9a75ec8c19373b482f6
React-cxxreact: c7b53ace5827be54048288bce5c55f337c41e95f
React-debug: 1f20c32441d0090cf67e6b966895f4ccd929d84c
Expand Down Expand Up @@ -2246,10 +2246,10 @@ SPEC CHECKSUMS:
React-utils: f747ea9fa3f4b293533ec4ef7976d1e37f004ef8
React-webperformancenativemodule: cf676ba871cc4b6ae175f75b92e8c689960c4141
ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2
ReactBrownfield: 9acfec561aae66f2adbf6faa451a0712b17a38b2
ReactBrownfield: efb9825ec1f9ad6fbfccc24aedf3ebd46b2e4cda
ReactCodegen: d9ba64702c846111b3eeb157ea2e15aa5bb2ea55
ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360
ReactNativeDependencies: 94375812b438d76ddfa8937a75fbb59cc0cd8fff
ReactNativeDependencies: bf5d16ce034aaabeeb0f7102f7db739529a3b910
RNScreens: 85c533985720c563272c6f6dd19e1278dfd0f5d4
Yoga: ce94692032f0a4e4ca7ed9e52a284cb208fcdbbb

Expand Down
6 changes: 3 additions & 3 deletions apps/RNApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"ios": "react-native run-ios",
"build:example:android-rn": "react-native build-android",
"build:example:ios-rn": "react-native build-ios",
"brownfield:package:android": "brownfield package:android --module-name :BrownfieldLib --variant release --verbose",
"brownfield:publish:android": "brownfield publish:android --module-name :BrownfieldLib --verbose",
"brownfield:package:ios": "brownfield package:ios --scheme BrownfieldLib --configuration Release --verbose",
"brownfield:package:android": "brownfield package:android --module-name :BrownfieldLib --variant release --verbose --output-dir ./android/.brownfield/navigation",
"brownfield:publish:android": "brownfield publish:android --module-name :BrownfieldLib --verbose --output-dir ./android/.brownfield/navigation",
"brownfield:package:ios": "brownfield package:ios --scheme BrownfieldLib --configuration Release --verbose --output-dir ./ios/.brownfield/navigation",
"lint": "eslint .",
"start": "react-native start",
"test": "jest --config jest.config.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ From your app root:
npx brownfield navigation:codegen
```

Optionally, write generated files to a custom app-local directory:

```bash
npx brownfield navigation:codegen --output-dir .brownfield/navigation-generated
```

## 3) What gets generated

Codegen updates `@callstack/brownfield-navigation` with your contract:
Expand All @@ -74,6 +80,19 @@ npx brownfield navigation:codegen

Then recompile native apps.

## 5) Maintainer workflow for packaging without dirtying package files

If you maintain this repo (or any monorepo where `@callstack/brownfield-navigation` sources are checked in), use a custom output directory and pass it to packaging commands:

```bash
npx brownfield navigation:codegen --output-dir .brownfield/navigation-generated
npx brownfield package:ios --scheme BrownfieldLib --output-dir .brownfield/navigation-generated
npx brownfield package:android --module-name :BrownfieldLib --output-dir .brownfield/navigation-generated
npx brownfield publish:android --module-name :BrownfieldLib --output-dir .brownfield/navigation-generated
```

Brownfield resolves `--output-dir` relative to your project root. If you omit it, Brownfield falls back to the default package-based generated paths.

## Next

- Continue with [Native Integration](/docs/api-reference/brownfield-navigation/native-integration)
Expand Down
16 changes: 16 additions & 0 deletions docs/docs/docs/cli/brownfield.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Available arguments:
| --destination | Define destination(s) for the build. You can pass multiple destinations as separate values or repeated use of the flag. Values: "simulator", "device", or xcodebuild destinations |
| --archive | Create an Xcode archive (IPA) of the build, required for uploading to App Store Connect or distributing to TestFlight |
| --use-prebuilt-rn-core [bool] | Controls usage of React Native Apple prebuilt binaries for the packaging Xcode build. Omit for version-aware defaults (see [Getting Started — iOS — React Native Prebuilts](/docs/getting-started/ios#react-native-prebuilts)). Pass `true`, `false`, or use the flag without a value as shorthand for `true`. Supported only for Expo 55+ OR vanilla RN >= 0.81. |
| --output-dir [path] | Custom output directory for generated Brownfield Navigation files used during packaging. Use this to keep generated files app-local instead of mutating `packages/brownfield-navigation`. |
| --no-install-pods | Skip automatic CocoaPods installation |
| --no-new-arch | Run React Native in legacy async architecture |
| --local | Force local build with xcodebuild |
Expand Down Expand Up @@ -70,6 +71,7 @@ Available arguments:
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| --variant | Specify your app's build variant, which is constructed from build type and product flavor, e.g. 'debug' or 'freeRelease'. (default: "debug") |
| --module-name | AAR module name |
| --output-dir [path] | Custom output directory for generated Brownfield Navigation files used during packaging. |
| --verbose | Enable verbose logging |

### Publish locally for Android
Expand All @@ -81,4 +83,18 @@ To publish the `.aar`(s) built beforehand with `npx brownfield publish:android`
| Argument | Description |
| ------------- | ---------------------- |
| --module-name | AAR module name |
| --output-dir [path] | Custom output directory for generated Brownfield Navigation files used during packaging. |
| --verbose | Enable verbose logging |

## Maintainer workflow for app-local navigation output

When packaging from a monorepo checkout, maintainers can keep `packages/brownfield-navigation` clean by generating navigation files into an app-local directory and passing the same directory to package commands.

```bash
npx brownfield navigation:codegen --output-dir .brownfield/navigation-generated
npx brownfield package:ios --scheme BrownfieldLib --output-dir .brownfield/navigation-generated
npx brownfield package:android --module-name :BrownfieldLib --output-dir .brownfield/navigation-generated
npx brownfield publish:android --module-name :BrownfieldLib --output-dir .brownfield/navigation-generated
```

`--output-dir` is resolved relative to your app project root. If omitted, Brownfield keeps default behavior and writes/reads generated navigation files from `@callstack/brownfield-navigation`.
48 changes: 47 additions & 1 deletion packages/brownfield-navigation/BrownfieldNavigation.podspec
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'json'
require 'shellwords'

package = JSON.parse(File.read(File.join(__dir__, 'package.json')))

Expand All @@ -13,7 +14,52 @@ Pod::Spec.new do |spec|
spec.platform = :ios, "14.0"

spec.source = { :git => "git@github.com:callstack/react-native-brownfield.git", :tag => "#{spec.version}" }
spec.source_files = "ios/**/*.{h,m,mm,swift}"

navigation_ios_source_root = ENV['BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT']
if navigation_ios_source_root && !navigation_ios_source_root.strip.empty?
resolved_navigation_ios_source_root = File.expand_path(navigation_ios_source_root, __dir__)
required_generated_source_files = [
File.join(resolved_navigation_ios_source_root, 'NativeBrownfieldNavigation.mm'),
File.join(resolved_navigation_ios_source_root, 'BrownfieldNavigationDelegate.swift'),
]

missing_generated_source_files = required_generated_source_files.reject { |file| File.exist?(file) }
if missing_generated_source_files.any?
raise "[BrownfieldNavigation] Missing generated iOS source files for override: #{missing_generated_source_files.join(', ')}"
end

override_source_files = required_generated_source_files.dup
optional_models_file = File.join(
resolved_navigation_ios_source_root,
'BrownfieldNavigationModels.swift'
)
override_source_files << optional_models_file if File.exist?(optional_models_file)

override_source_dir = File.join(__dir__, 'ios', '.brownfield_navigation_override')
linked_override_source_files = override_source_files.map do |source_file|
File.join(override_source_dir, File.basename(source_file))
end

symlink_commands = override_source_files.zip(linked_override_source_files).map do |source_file, destination_file|
"ln -sf #{Shellwords.escape(source_file)} #{Shellwords.escape(destination_file)}"
end

spec.prepare_command = <<-CMD
set -e
rm -rf #{Shellwords.escape(override_source_dir)}
mkdir -p #{Shellwords.escape(override_source_dir)}
#{symlink_commands.join("\n ")}
CMD

spec.source_files = [
'ios/NativeBrownfieldNavigation.h',
'ios/BrownfieldNavigationManager.swift',
*linked_override_source_files.map { |file| file.sub("#{__dir__}/", '') },
]
else
spec.source_files = "ios/**/*.{h,m,mm,swift}"
end

spec.pod_target_xcconfig = {
# below: needed to build the XCFramework with `.swiftinterface` files, required by xcodebuild -create-xcframework to succeed
'DEFINES_MODULE' => 'YES',
Expand Down
82 changes: 82 additions & 0 deletions packages/brownfield-navigation/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,35 @@ apply plugin: "kotlin-android"

apply plugin: "com.facebook.react"

def navigationAndroidGeneratedSourceDirPropertyName =
"brownfieldNavigationAndroidGeneratedSourceDir"
def navigationAndroidGeneratedSourceDir =
project.findProperty(navigationAndroidGeneratedSourceDirPropertyName)?.toString()?.trim()
def navigationGeneratedOutputRoot
def navigationGeneratedJsSourceDir

if (navigationAndroidGeneratedSourceDir) {
navigationAndroidGeneratedSourceDir = file(navigationAndroidGeneratedSourceDir).absolutePath
if (!file(navigationAndroidGeneratedSourceDir).exists()) {
throw new GradleException(
"Configured ${navigationAndroidGeneratedSourceDirPropertyName} path does not exist: ${navigationAndroidGeneratedSourceDir}"
)
}

navigationGeneratedOutputRoot = file(navigationAndroidGeneratedSourceDir)
.toPath()
.resolve("../../../../")
.normalize()
.toFile()
.absolutePath
navigationGeneratedJsSourceDir = file(navigationGeneratedOutputRoot).toPath().resolve("src").normalize().toFile().absolutePath
if (!file(navigationGeneratedJsSourceDir).exists()) {
throw new GradleException(
"Configured ${navigationAndroidGeneratedSourceDirPropertyName} implies JS codegen source dir that does not exist: ${navigationGeneratedJsSourceDir}"
)
}
}

android {
namespace "com.callstack.nativebrownfieldnavigation"

Expand Down Expand Up @@ -60,6 +89,59 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

sourceSets {
main {
if (navigationAndroidGeneratedSourceDir) {
java.srcDirs += [navigationAndroidGeneratedSourceDir]
if (hasProperty('kotlin')) {
kotlin.srcDirs += [navigationAndroidGeneratedSourceDir]
}
}
}
}
}

if (navigationAndroidGeneratedSourceDir) {
// Keep RN codegen schema generation in sync with overridden generated artifacts.
tasks.matching { task ->
task.name == "generateCodegenSchemaFromJavaScript"
}.configureEach { task ->
task.jsRootDir.set(file(navigationGeneratedJsSourceDir))
task.jsInputFiles.set(
fileTree(task.jsRootDir) { tree ->
tree.include("**/*.js")
tree.include("**/*.jsx")
tree.include("**/*.ts")
tree.include("**/*.tsx")

tree.exclude("node_modules/**/*")
tree.exclude("**/*.d.ts")
tree.exclude("**/build/**/*")
}
)
}

def generatedFileNames = [
"BrownfieldNavigationDelegate.kt",
"NativeBrownfieldNavigationModule.kt",
"BrownfieldNavigationModels.kt"
]
def defaultGeneratedFilePaths = generatedFileNames.collectEntries { fileName ->
[(file("src/main/java/com/callstack/nativebrownfieldnavigation/${fileName}").absolutePath):
file("${navigationAndroidGeneratedSourceDir}/com/callstack/nativebrownfieldnavigation/${fileName}").absolutePath]
}

// Only exclude checked-in generated sources when the override counterpart is present.
// This preserves a safe fallback when override files are missing (e.g. cleaned build dir).
tasks.matching { task ->
task.name.startsWith("compile") && task.name.endsWith("Kotlin")
}.configureEach { task ->
task.exclude { details ->
def overrideFilePath = defaultGeneratedFilePaths[details.file.absolutePath]
overrideFilePath != null && file(overrideFilePath).exists()
}
}
}

dependencies {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { Command, Option } from 'commander';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import * as rockTools from '@rock-js/tools';
import { describe, expect, test } from 'vitest';

import { parseUsePrebuiltRnCoreArgument } from '../packageIos.js';
import {
BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR,
parseUsePrebuiltRnCoreArgument,
resolveNavigationIosSourceRoot,
withNavigationIosSourceRootEnv,
} from '../packageIos.js';

/** Mirrors `--use-prebuilt-rn-core` on `packageIosCommand` (preset + parser). */
function parsePackageIosArgv(argv: string[]) {
Expand Down Expand Up @@ -64,3 +72,52 @@ describe('--use-prebuilt-rn-core (Commander)', () => {
).toBe(false);
});
});

describe('resolveNavigationIosSourceRoot', () => {
test('returns undefined when outputDir is absent', () => {
expect(resolveNavigationIosSourceRoot('/tmp/project')).toBeUndefined();
});

test('resolves outputDir relative to project root and appends ios', () => {
expect(
resolveNavigationIosSourceRoot('/tmp/project', '.brownfield/navigation')
).toBe(path.join('/tmp/project', '.brownfield', 'navigation', 'ios'));
});
});

describe('withNavigationIosSourceRootEnv', () => {
test('sets and restores env var around execution', async () => {
const tempProjectRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'brownfield-ios-env-test-')
);
const outputDir = '.brownfield/navigation';
const iosSourceRoot = path.join(tempProjectRoot, outputDir, 'ios');
fs.mkdirSync(iosSourceRoot, { recursive: true });
delete process.env[BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR];

const seenInside = await withNavigationIosSourceRootEnv({
projectRoot: tempProjectRoot,
outputDir,
run: async () => process.env[BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR],
});

expect(seenInside).toBe(iosSourceRoot);
expect(
process.env[BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR]
).toBeUndefined();
});

test('throws when custom output does not provide ios generated sources', async () => {
const tempProjectRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'brownfield-ios-env-test-')
);

await expect(
withNavigationIosSourceRootEnv({
projectRoot: tempProjectRoot,
outputDir: '.brownfield/missing-navigation-output',
run: async () => undefined,
})
).rejects.toThrow(rockTools.RockError);
});
});
Loading
Loading