Skip to content

Commit ea3c29b

Browse files
authored
feat(react-native): Expo SDK 57 / RN 0.86.2 upgrade and iOS 27 compatibility fixes (#2355)
### 💡 Overview Upgrades the three React Native sample apps to **Expo SDK 57 / React Native 0.86.2** and verifies them against the **iOS 27 SDK** (Xcode 27.0 Beta 4) and **Android 17 (API 37)**. Verification surfaced two bugs in the SDK's Expo config plugin. Both are fixed here, each in its own commit, and neither is specific to the sample apps: - the screen share broadcast extension hardcoded a deployment target Xcode 27 rejects, and never wrote `DEVELOPMENT_TEAM` into the extension's build configurations - the Android FCM base class was detected with `require.resolve`, which mis-picks a hoisted package in any workspace/monorepo setup | App | iOS 27 build | iOS runtime | Android 17 build | Android 17 runtime | | --- | --- | --- | --- | --- | | dogfood | ✅ | ✅ full call, remote video decode, PiP, clean leave | ✅ | ✅ full call, publisher ICE `completed`, clean leave | | expo-video-sample | ✅ | ✅ call joined, audio session lifecycle | ✅ | ✅ call joined, 2 peer connections | | ringing-tutorial | ✅ | ✅ boots | ✅ | ✅ FCM push token registered with Stream | ### 📝 Implementation notes **Upgrade.** Expo 57 pins RN 0.86.2 and React 19.2.3, so React does not move. `reanimated`, `worklets`, `screens` and `gesture-handler` go to their SDK 57 versions; `react-native-svg` and `safe-area-context` are deliberately held **above** Expo's pins via `expo.install.exclude`. `react-native-mmkv` 3 → 4 is the only breaking companion bump: `new MMKV()` becomes `createMMKV()`, and the library is now a Nitro module, so `react-native-nitro-modules` is a new dependency. Verified on device that the existing persisted store survives the migration (9 key-values loaded intact). **Deliberately excluded**, both needing their own PR: - `@react-native-firebase` stays on **v24**. v26 removes the namespaced API entirely (no default export), which the SDK's detection shim reads. Supporting it means dual-API support in `packages/react-native-sdk` given the `>= 17.5.0` peer range. The deprecation warnings are already firing at runtime on both platforms, naming `getApp()`, `setBackgroundMessageHandler` and `onMessage`. - `react-native-vision-camera` stays on **v4**. v5 is a Nitro rewrite that removes `useCodeScanner` from core and adds two new peer deps; dogfood's QR scanner would need rebuilding against a separate plugin. **Regenerated `dogfood/ios/Podfile.lock`.** The committed lock still pinned `React-Core-prebuilt 0.85.3` while `React-Core`, `ReactNativeDependencies` and `hermes-engine` had moved to 0.86.2. That combination **compiles cleanly but crashes at launch** with a missing JSI symbol (`facebook::jsi::Array::createWithElements`), and a plain `pod install` does not re-resolve it — the lock has to be deleted. Worth knowing for anyone else bumping RN in a bare app; the Expo apps were immune because their lock is untracked and regenerated by `prebuild`. **Broadcast extension fix.** `IPHONEOS_DEPLOYMENT_TARGET` was hardcoded to `14.0`. Xcode 27 raised the supported minimum to 15.0 and errors below it, so every Expo app using the screen share plugin failed against the iOS 27 SDK. It now derives from the highest deployment target already in the host project and only ever raises, so apps on older RN versions keep the 14.0 floor and do not lose screen share on older devices. Separately, `DEVELOPMENT_TEAM` was only set in the project's `TargetAttributes`, which `xcodebuild` ignores for automatic signing — that made extension signing fail on Xcode 26.6 as well, so this part is not iOS 27 specific. **FCM base class detection fix.** `require.resolve` walks up the directory tree, so from `ringing-tutorial` it found the `expo-notifications` that `expo-video-sample` hoisted to the repo root. That package is not autolinked into `ringing-tutorial`, so the generated `StreamVideoMessagingService` extended a class absent from the compile classpath and `:app:compileDebugKotlin` failed with `Unresolved reference`. Detection now uses two hoisting-independent signals (the app declares the package, or it is installed under the app), neither of which looks above the app directory. Confirmed against Expo's own autolinking, which resolves `expo-notifications` for `expo-video-sample` but not for `ringing-tutorial`. This fix does **not** add auto-detection of `@react-native-firebase/messaging` as a base class, which would change behaviour for existing consumers. `ringing-tutorial` therefore keeps an explicit `androidMessagingServiceBaseClass` in its `app.json` so its own RNFirebase handlers keep receiving non-ringing messages. That override is no longer load-bearing for the build. 🎫 Ticket: https://linear.app/stream/issue/RN-418/expo-sdk-57-rn-0862-upgrade-for-rn-sample-apps-plus-ios-27 📑 Docs: _no docs change needed. The RN 0.86 `Podfile.lock` gotcha may be worth a release-note line._ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Improved Expo iOS broadcast extension setup with automatic deployment-target resolution and development-team configuration. * Improved Android messaging-service detection for workspace and hoisted dependency layouts. * Updated sample applications for current Expo and React Native releases. * **Bug Fixes** * Improved compatibility with newer React Native, Expo, WebRTC, and build tooling versions. * **Tests** * Added coverage for iOS deployment-target resolution and Android notification-package detection. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent eedc526 commit ea3c29b

23 files changed

Lines changed: 1920 additions & 1527 deletions

File tree

.yarnrc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ nmMode: hardlinks-global
1212

1313
nodeLinker: node-modules
1414

15-
npmMinimalAgeGate: 3d
15+
npmMinimalAgeGate: 2d
1616

1717
npmPreapprovedPackages:
1818
- stream-chat

packages/client/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"@protobuf-ts/twirp-transport": "^2.11.1",
3737
"@stream-io/logger": "^2.0.0",
3838
"@stream-io/worker-timer": "^1.2.5",
39-
"axios": "^1.16.1",
39+
"axios": "^1.19.0",
4040
"rxjs": "~7.8.2",
4141
"sdp-transform": "^2.15.0",
4242
"ua-parser-js": "^1.0.41",
@@ -47,7 +47,7 @@
4747
"@rollup/plugin-replace": "^6.0.3",
4848
"@rollup/plugin-typescript": "^12.3.0",
4949
"@stream-io/audio-filters-web": "workspace:^",
50-
"@stream-io/node-sdk": "^0.7.59",
50+
"@stream-io/node-sdk": "^0.7.63",
5151
"@stream-io/typescript-config": "workspace:^",
5252
"@total-typescript/shoehorn": "^0.1.2",
5353
"@types/node": "^25.9.3",

packages/codemod/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"directory": "packages/codemod"
2222
},
2323
"dependencies": {
24-
"jscodeshift": "^17.3.0"
24+
"jscodeshift": "^17.4.0"
2525
},
2626
"devDependencies": {
2727
"@types/jscodeshift": "^17.3.0",

packages/noise-cancellation-react-native/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,16 @@
4848
},
4949
"homepage": "https://github.com/GetStream/stream-video-js#readme",
5050
"devDependencies": {
51-
"@stream-io/react-native-webrtc": "145.0.0",
51+
"@stream-io/react-native-webrtc": "145.1.1",
5252
"@stream-io/typescript-config": "workspace:^",
5353
"react": "19.2.3",
54-
"react-native": "0.85.3",
55-
"react-native-builder-bob": "^0.41.0",
54+
"react-native": "0.86.2",
55+
"react-native-builder-bob": "^0.43.0",
5656
"rimraf": "^6.1.3",
5757
"typescript": "^6.0.3"
5858
},
5959
"peerDependencies": {
60-
"@stream-io/react-native-webrtc": "^145.0.0",
60+
"@stream-io/react-native-webrtc": "^145.1.1",
6161
"react-native": "*"
6262
},
6363
"react-native-builder-bob": {

packages/react-native-callingx/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,15 @@
5959
"registry": "https://registry.npmjs.org/"
6060
},
6161
"devDependencies": {
62-
"@react-native-community/cli": "20.1.3",
63-
"@react-native/babel-preset": "0.85.3",
62+
"@react-native-community/cli": "20.2.0",
63+
"@react-native/babel-preset": "0.86.2",
6464
"@stream-io/react-native-webrtc": "145.1.1",
6565
"@stream-io/typescript-config": "workspace:^",
6666
"@types/react": "^19.2.15",
6767
"del-cli": "^6.0.0",
6868
"react": "19.2.3",
69-
"react-native": "0.85.3",
70-
"react-native-builder-bob": "^0.41.0",
69+
"react-native": "0.86.2",
70+
"react-native-builder-bob": "^0.43.0",
7171
"typescript": "^6.0.3"
7272
},
7373
"peerDependencies": {
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import addBroadcastExtensionXcodeTarget, {
2+
MIN_DEPLOYMENT_TARGET,
3+
resolveDeploymentTarget,
4+
} from '../src/withIosScreenCapture/addBroadcastExtensionXcodeTarget';
5+
6+
const DEVELOPMENT_TEAM_ID = 'ABCDE12345';
7+
8+
type StubOptions = {
9+
/** Deployment targets on the host app target's own configurations. */
10+
appTargets?: (string | number | undefined)[];
11+
/** Deployment targets on the project-level configurations. */
12+
projectTargets?: (string | number | undefined)[];
13+
/**
14+
* Deployment targets belonging to targets other than the host app (widgets,
15+
* other extensions). These must never influence the resolved value.
16+
*/
17+
otherTargets?: (string | number | undefined)[];
18+
};
19+
20+
/**
21+
* Minimal stand-in for the parts of the `xcode` project object that
22+
* addBroadcastExtensionXcodeTarget touches. Captures the build configurations
23+
* it generates so they can be asserted on.
24+
*
25+
* Models real project structure: configurations live in the flat
26+
* XCBuildConfiguration section, and each target reaches its own subset through
27+
* an XCConfigurationList.
28+
*/
29+
const createProjectStub = (
30+
options: StubOptions | (string | number | undefined)[],
31+
) => {
32+
const {
33+
appTargets = [],
34+
projectTargets = [],
35+
otherTargets = [],
36+
} = Array.isArray(options) ? { appTargets: options } : options;
37+
38+
const buildConfigurationSection: Record<string, any> = {};
39+
const configurationLists: Record<string, any> = {};
40+
41+
const addList = (
42+
listUuid: string,
43+
targets: (string | number | undefined)[],
44+
prefix: string,
45+
) => {
46+
const buildConfigurations = targets.map((target, index) => {
47+
const uuid = `${prefix}${index}`;
48+
buildConfigurationSection[uuid] = {
49+
isa: 'XCBuildConfiguration',
50+
buildSettings:
51+
target === undefined ? {} : { IPHONEOS_DEPLOYMENT_TARGET: target },
52+
};
53+
buildConfigurationSection[`${uuid}_comment`] = 'Debug';
54+
return { value: uuid, comment: 'Debug' };
55+
});
56+
configurationLists[listUuid] = { buildConfigurations };
57+
};
58+
59+
addList('APP_LIST', appTargets, 'APP');
60+
addList('PROJECT_LIST', projectTargets, 'PROJ');
61+
// reachable in the flat section but not from the app or project lists
62+
addList('OTHER_LIST', otherTargets, 'OTHER');
63+
64+
let uuidCounter = 0;
65+
66+
// the module mutates this in place, so every call must see the same object
67+
const projectSection: Record<string, any> = {
68+
PROJECT_UUID: { attributes: {} },
69+
};
70+
71+
return {
72+
addedConfigurations: [] as any[],
73+
pbxXCBuildConfigurationSection: () => buildConfigurationSection,
74+
pbxXCConfigurationList: () => configurationLists,
75+
generateUuid: () => `GENERATED${uuidCounter++}`,
76+
getFirstProject: () => ({
77+
uuid: 'PROJECT_UUID',
78+
firstProject: { targets: [], buildConfigurationList: 'PROJECT_LIST' },
79+
}),
80+
getFirstTarget: () => ({
81+
firstTarget: { name: 'TestApp', buildConfigurationList: 'APP_LIST' },
82+
}),
83+
addXCConfigurationList(configurations: any[]) {
84+
this.addedConfigurations = configurations;
85+
return { uuid: 'CONFIG_LIST_UUID' };
86+
},
87+
updateBuildProperty: () => undefined,
88+
addFramework: () => ({ path: 'ReplayKit.framework' }),
89+
addToPbxFileReferenceSection: () => undefined,
90+
addToPbxBuildFileSection: () => undefined,
91+
addToPbxNativeTargetSection: () => undefined,
92+
addToPbxProjectSection: () => undefined,
93+
addTargetDependency: () => undefined,
94+
addBuildPhase: () => ({ buildPhase: {} }),
95+
addToPbxGroup: () => undefined,
96+
addPbxGroup: () => ({ uuid: 'GROUP_UUID' }),
97+
pbxProjectSection: () => projectSection,
98+
hash: { project: { objects: { PBXGroup: {} } as Record<string, any> } },
99+
};
100+
};
101+
102+
describe('resolveDeploymentTarget', () => {
103+
it('falls back to the minimum when the project declares no target', () => {
104+
const proj = createProjectStub([]);
105+
expect(resolveDeploymentTarget(proj as any)).toBe(MIN_DEPLOYMENT_TARGET);
106+
});
107+
108+
it('never lowers the target below the extension minimum', () => {
109+
const proj = createProjectStub(['12.0', '13.4']);
110+
expect(resolveDeploymentTarget(proj as any)).toBe(MIN_DEPLOYMENT_TARGET);
111+
});
112+
113+
// An app may pin ios.deploymentTarget to the toolchain minimum via
114+
// expo-build-properties. The extension must not land above it, or screen
115+
// share becomes unavailable on the oldest devices the app still supports.
116+
it('does not exceed a host app pinned to the toolchain minimum', () => {
117+
const proj = createProjectStub(['15.0', '15.0']);
118+
expect(resolveDeploymentTarget(proj as any)).toBe('15.0');
119+
});
120+
121+
it("raises the target to match the host app's deployment target", () => {
122+
const proj = createProjectStub(['16.4', '16.4']);
123+
expect(resolveDeploymentTarget(proj as any)).toBe('16.4');
124+
});
125+
126+
it('picks the highest target when configurations disagree', () => {
127+
const proj = createProjectStub(['15.1', '16.4', '14.0']);
128+
expect(resolveDeploymentTarget(proj as any)).toBe('16.4');
129+
});
130+
131+
it('compares versions numerically rather than lexically', () => {
132+
// lexically '9.0' sorts above '16.0'
133+
const proj = createProjectStub(['9.0', '16.0']);
134+
expect(resolveDeploymentTarget(proj as any)).toBe('16.0');
135+
});
136+
137+
it('ignores quoted values and non-version placeholders', () => {
138+
const proj = createProjectStub(['"16.4"', '$(inherited)', undefined]);
139+
expect(resolveDeploymentTarget(proj as any)).toBe('16.4');
140+
});
141+
142+
it('rejects versions with a non-numeric suffix', () => {
143+
// Number.parseInt('4beta') is 4, so a naive parse would adopt "16.4beta"
144+
// verbatim and write an invalid value into the build setting.
145+
const proj = createProjectStub(['16.4beta']);
146+
expect(resolveDeploymentTarget(proj as any)).toBe(MIN_DEPLOYMENT_TARGET);
147+
148+
const withValid = createProjectStub(['16.4beta', '16.1']);
149+
expect(resolveDeploymentTarget(withValid as any)).toBe('16.1');
150+
});
151+
152+
it('reads the project-level target when the app target inherits it', () => {
153+
const proj = createProjectStub({
154+
appTargets: ['$(inherited)'],
155+
projectTargets: ['16.4'],
156+
});
157+
expect(resolveDeploymentTarget(proj as any)).toBe('16.4');
158+
});
159+
160+
// The extension must never require a newer iOS than the app it ships inside:
161+
// a widget or notification-service extension on a higher target would
162+
// otherwise silently drop screen share on devices in between.
163+
it('ignores deployment targets belonging to unrelated targets', () => {
164+
const proj = createProjectStub({
165+
appTargets: ['16.1', '16.1'],
166+
projectTargets: ['16.1'],
167+
otherTargets: ['17.0', '18.0'],
168+
});
169+
expect(resolveDeploymentTarget(proj as any)).toBe('16.1');
170+
});
171+
172+
// Xcode 27 errors out on anything below 15.0, so even the fallback taken when
173+
// the host app's target cannot be read has to clear that bar.
174+
it('always resolves to a target Xcode 27 accepts', () => {
175+
const projects = [
176+
createProjectStub([]),
177+
createProjectStub(['13.4']),
178+
createProjectStub(['16.4']),
179+
];
180+
projects.forEach((proj) => {
181+
const [major, minor = 0] = resolveDeploymentTarget(proj as any)
182+
.split('.')
183+
.map(Number);
184+
expect(major * 100 + minor).toBeGreaterThanOrEqual(15 * 100);
185+
});
186+
});
187+
});
188+
189+
describe('addBroadcastExtensionXcodeTarget', () => {
190+
const addTarget = (deploymentTargets: string[]) => {
191+
const proj = createProjectStub(deploymentTargets);
192+
addBroadcastExtensionXcodeTarget(proj as any, {
193+
appName: 'TestApp',
194+
extensionName: 'broadcast',
195+
extensionBundleIdentifier: 'io.getstream.test.broadcast',
196+
currentProjectVersion: '1',
197+
marketingVersion: '1.0.0',
198+
developmentTeamId: DEVELOPMENT_TEAM_ID,
199+
});
200+
return proj.addedConfigurations;
201+
};
202+
203+
it('writes DEVELOPMENT_TEAM into every build configuration', () => {
204+
const configurations = addTarget(['16.4']);
205+
206+
expect(configurations).toHaveLength(2);
207+
expect(configurations.map((c) => c.name)).toEqual(['Debug', 'Release']);
208+
configurations.forEach((configuration) => {
209+
expect(configuration.buildSettings.DEVELOPMENT_TEAM).toBe(
210+
DEVELOPMENT_TEAM_ID,
211+
);
212+
});
213+
});
214+
215+
it('applies the resolved deployment target to every build configuration', () => {
216+
const configurations = addTarget(['16.4']);
217+
218+
configurations.forEach((configuration) => {
219+
expect(configuration.buildSettings.IPHONEOS_DEPLOYMENT_TARGET).toBe(
220+
'16.4',
221+
);
222+
});
223+
});
224+
225+
it('falls back to the minimum deployment target for older host apps', () => {
226+
const configurations = addTarget(['13.4']);
227+
228+
configurations.forEach((configuration) => {
229+
expect(configuration.buildSettings.IPHONEOS_DEPLOYMENT_TARGET).toBe(
230+
MIN_DEPLOYMENT_TARGET,
231+
);
232+
});
233+
});
234+
});

0 commit comments

Comments
 (0)