Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/bare-expo/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3913,7 +3913,7 @@ SPEC CHECKSUMS:
ExpoModulesWorklets: 6b240031daa3de4df791588fc78236637ff602f6
ExpoModulesWorkletsAdapter: add08a40add90b989d4f399e49508e34c6cf0334
ExpoNetwork: 973d523fe9b1f99223dd945fed9ad1226ae64920
ExpoNotifications: 0f253ad90f108c1c4045b541294164865c6aadc8
ExpoNotifications: 2cb244d406414754b7159f1c7a9fd2d4754e7f47
ExpoObserve: aa4fcbe7ed0a7ba387984a38e24c2ecaa8895fd0
ExpoPrint: 7e7a9bc7c12b9b336f34c4458d0c9ba14f243759
ExpoRouter: 224087330cc8f5a39700e6f4d7a55f55ecb2f90d
Expand Down
32 changes: 32 additions & 0 deletions docs/pages/router/advanced/native-tabs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,38 @@ export default function TabLayout() {

## Known limitations

<Collapsible summary="The default and selected icons share one rendering mode on iOS">

On iOS, a tab's default and selected image icons must use the same [rendering mode](#icon-rendering-mode). When they resolve to different modes, both icons use the default icon's mode and Expo Router logs a warning in development.

The modes disagree when an icon color applies to only one of the states. This happens when you set `tintColor`, `iconColor={{ selected }}`, or the `Icon` `selectedColor` prop without also setting a color for the default state. A color implies `'template'` rendering, while an uncolored icon defaults to `'original'`. Setting `renderingMode` for only one state has the same effect.

To render both icons the same way, set a color for both states, or set `renderingMode` on the `Icon`:

```tsx src/app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';

export default function TabLayout() {
return (
// `iconColor` applies to both states, so both icons render as templates
<NativeTabs iconColor={{ default: 'gray', selected: 'black' }}>
<NativeTabs.Trigger name="settings">
<NativeTabs.Trigger.Icon
src={{
default: require('../assets/setting_icon.png'),
selected: require('../assets/selected_setting_icon.png'),
}}
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}
```

This limitation doesn't apply to SF Symbols, which the system always tints.

</Collapsible>

<Collapsible summary="A limit of 5 tabs on Android">

On Android, there is a limitation of having a maximum of 5 tabs in the tab bar. This restriction comes from the platform's Material Tabs component.
Expand Down

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/@expo/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

### 🐛 Bug fixes

- Stop writing DOM component source maps into the app binary during `expo export:embed`, so Android release builds no longer ship `www.bundle` source maps with the original app source. ([#49480](https://github.com/expo/expo/pull/49480) by [@expo-bot](https://github.com/expo-bot))
- Serve relative manifest URLs only when the client itself sends the RFC 7239 `Forwarded` header, so that proxied requests from clients without relative-URL support, like released Expo Go versions through the WS tunnel, keep absolute URLs. ([#48997](https://github.com/expo/expo/pull/48997) by [@expo-bot](https://github.com/expo-bot))
- Fail when `--private-key-path` is passed without `updates.codeSigningCertificate` in the resolved app config, instead of ignoring the flag and continuing without signing.
- Show the Xcode build log path when `run:ios` fails. ([#48624](https://github.com/expo/expo/pull/48624) by [@ramonclaudio](https://github.com/ramonclaudio))
Expand Down
3 changes: 2 additions & 1 deletion packages/@expo/cli/src/export/embed/exportEmbedAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,8 @@ export async function exportEmbedBundleAndAssetsAsync(
dev: options.dev,
devServer,
isHermes,
includeSourceMaps: !!sourceMapUrl,
// don't ship sourcemap in the www.bundle
includeSourceMaps: false,
exp,
files,
});
Expand Down
2 changes: 2 additions & 0 deletions packages/expo-modules-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
### 🐛 Bug fixes

- [iOS][Android] Fixed a `matchContents` `RNHostView` and the `matchContents` host around it feeding each other's size back and forth, which grew the layout on every pass. ([#49483](https://github.com/expo/expo/pull/49483) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
- [iOS] Fixed SwiftUI view props re-decoding every field on every props update. Fabric sends the whole props map rather than a delta, so an unrelated prop change replaced each decoded value with an equal but distinct one, which cost a decode per field and stopped SwiftUI from pruning the view tree that reads it. A field whose raw value is unchanged now keeps the value decoded before, the same way `ExpoFabricView.updateProps` already worked for UIKit views. ([#48426](https://github.com/expo/expo/pull/48426) by [@nishan](https://github.com/intergalacticspacehighway))
- [iOS] Fixed `matchContents` hosts sometimes being laid out at a stale size. Regression from [#48059](https://github.com/expo/expo/pull/48059). ([#49211](https://github.com/expo/expo/pull/49211) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
- [iOS] Fixed an infinite main-thread layout loop (frozen UI, watchdog kill on backgrounding) when a SwiftUI host with `matchContents` and Yoga persistently disagree on the content size, e.g. with the Button Shapes accessibility setting enabled. Synchronous size commits are now budgeted per run-loop turn; over-budget updates are coalesced on the view and committed asynchronously on the next turn, and no-op size updates no longer dirty the layout. ([#48058](https://github.com/expo/expo/issues/48058), [#48059](https://github.com/expo/expo/pull/48059) by [@focux](https://github.com/focux))
- [iOS] Fixed the `ExpoModulesProvider` lookup missing the generated class when the app `name` starts with a digit, which registered no native modules and left release builds on a blank screen. ([#48793](https://github.com/expo/expo/pull/48793) by [@expo-bot](https://github.com/expo-bot))
Expand Down Expand Up @@ -52,6 +53,7 @@
- [iOS] Added `SceneGeometry.foregroundScene()`, which returns nil when no scene is on screen so callers can avoid presenting UI into a background scene. ([#48318](https://github.com/expo/expo/pull/48318) by [@alanjhughes](https://github.com/alanjhughes))
- Removed Quick and Nimble in favor of Swift Testing. ([#48530](https://github.com/expo/expo/pull/48530) by [@tsapeta](https://github.com/tsapeta))
- Migrated from deprecated react-native-worklets WorkletRuntime API `executeSync` to up-to-date `runSync`. `runSync` is available since 0.7.0. ([#48691](https://github.com/expo/expo/pull/48691) by [@tjzel](https://github.com/tjzel))
- Added internal `ExpoModulesProviderModuleName` lookup key for `ExpoModulesProvider` class. ([#49539](https://github.com/expo/expo/pull/49539) by [@kudo](https://github.com/kudo))

## 57.0.8 - 2026-07-29

Expand Down
12 changes: 7 additions & 5 deletions packages/expo-modules-core/ios/Core/AppContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -724,12 +724,14 @@ public final class AppContext: NSObject, EXAppContextProtocol, @unchecked Sendab
*/
@objc
public static func modulesProvider(withName providerName: String = "ExpoModulesProvider") -> ModulesProvider {
// [0] When ExpoModulesCore is built as separated framework/module,
// we should explicitly load main bundle's `ExpoModulesProvider` class.
// CFBundleExecutable is tried first: it is the product name, from which the Swift module
// name is derived. CFBundleName is kept as a fallback for the uncommon case where both
// values are identical valid identifiers.
// [0] When ExpoModulesCore is built as a separate framework/module,
// explicitly load the main bundle's `ExpoModulesProvider` class.
// `ExpoModulesProviderModuleName` is an internal key that allows repack-app to
// preserve the original Swift module name. Try `CFBundleExecutable` next because
// it usually matches the Swift module name. Keep `CFBundleName` as a final fallback
// for cases where it is also a valid module identifier.
let mainBundleNames = [
Bundle.main.infoDictionary?["ExpoModulesProviderModuleName"],
Bundle.main.infoDictionary?["CFBundleExecutable"],
Bundle.main.infoDictionary?["CFBundleName"]
].compactMap { $0 as? String }
Expand Down
17 changes: 17 additions & 0 deletions packages/expo-modules-core/ios/Core/Conversions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ public struct Conversions {
}
}

/**
Compares two prop values for equality, to tell an actual prop change from a re-delivery of the
same value.
*/
static func areValuesEqual(_ lhs: Any?, _ rhs: Any?) -> Bool {
switch (lhs, rhs) {
case (nil, nil):
return true
case let (lhsValue as AnyHashable, rhsValue as AnyHashable):
return lhsValue == rhsValue
case let (lhsValue as NSObjectProtocol, rhsValue as NSObjectProtocol):
return lhsValue.isEqual(rhsValue)
default:
return false
}
}

static func fromNSObject(_ object: Any) -> Any {
switch object {
case let object as NSArray:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,31 @@ extension ExpoSwiftUI {
*/
public let globalEventDispatcher = EventDispatcher(GLOBAL_EVENT_NAME)

/**
A dictionary to store previous raw prop values for change detection.
*/
private var previousRawProps: [String: Any] = [:]

internal func updateRawProps(_ rawProps: [String: Any], appContext: AppContext) throws {
// Update the props just like the records
try update(withDict: rawProps, appContext: appContext)
try fieldsOf(self).forEach { field in
guard let key = field.key else {
return
}
guard rawProps.keys.contains(key) else {
if field.isRequired {
try field.set(nil, appContext: appContext)
}
return
}
let newValue = rawProps[key]
let previousValue = previousRawProps[key]

if !Conversions.areValuesEqual(previousValue, newValue) {
try field.set(newValue, appContext: appContext)

previousRawProps[key] = newValue
}
}

// Notify subscribed views about the change to re-render them.
objectWillChange.send()
Expand Down
17 changes: 1 addition & 16 deletions packages/expo-modules-core/ios/Fabric/ExpoFabricView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ open class ExpoFabricView: ExpoFabricViewObjC, AnyExpoView {
let previousValue = previousProps[key]

// only set the prop if the value has changed
if !areValuesEqual(previousValue, convertedNewValue) {
if !Conversions.areValuesEqual(previousValue, convertedNewValue) {
// TODO: @tsapeta: Figure out better way to rethrow errors from here.
// Adding `throws` keyword to the function results in different
// method signature in Objective-C. Maybe just call `RCTLogError`?
Expand All @@ -102,21 +102,6 @@ open class ExpoFabricView: ExpoFabricViewObjC, AnyExpoView {
}
}

/**
Helper function to compare two values for equality using string representation.
*/
private func areValuesEqual(_ lhs: Any?, _ rhs: Any?) -> Bool {
switch (lhs, rhs) {
case (nil, nil):
return true
case let (lhsValue as AnyHashable, rhsValue as AnyHashable):
return lhsValue == rhsValue
case let (lhsValue as NSObjectProtocol, rhsValue as NSObjectProtocol):
return lhsValue.isEqual(rhsValue)
default:
return false
}
}
/**
Calls lifecycle methods registered by `OnViewDidUpdateProps` definition component.
*/
Expand Down
61 changes: 61 additions & 0 deletions packages/expo-modules-core/ios/Tests/SwiftUIViewPropsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2026-present 650 Industries. All rights reserved.

import Foundation
import Testing

@testable import ExpoModulesCore

/**
A class rather than a struct, so that decoding it again produces a new instance and `===` tells a
preserved value apart from a freshly decoded one.
*/
private final class Marker: Record {
@Field var text: String?

init() {}
}

private final class TestViewProps: ExpoSwiftUI.ViewProps {
@Field var marker: Marker?
@Field var title: String?
}

// swiftlint:disable legacy_objc_type
private func makeRawProps(markerText: String = "marker", title: String = "hello") -> [String: Any] {
return [
"marker": ["text": markerText] as NSDictionary,
"title": title as NSString
]
}
// swiftlint:enable legacy_objc_type

@Suite("ExpoSwiftUI.ViewProps")
struct SwiftUIViewPropsTests {
let appContext = AppContext.create()

@Test
func `keeps the decoded value when its raw value is unchanged`() throws {
let props = TestViewProps()
try props.updateRawProps(makeRawProps(), appContext: appContext)
let firstMarker = props.marker
#expect(firstMarker != nil)

// Only `title` changes. `marker` arrives as an equal but freshly allocated dictionary.
try props.updateRawProps(makeRawProps(title: "world"), appContext: appContext)

#expect(props.marker === firstMarker)
#expect(props.title == "world")
}

@Test
func `decodes a field again when its raw value changes`() throws {
let props = TestViewProps()
try props.updateRawProps(makeRawProps(), appContext: appContext)
let firstMarker = props.marker

try props.updateRawProps(makeRawProps(markerText: "other"), appContext: appContext)

#expect(props.marker !== firstMarker)
#expect(props.marker?.text == "other")
}
}
1 change: 1 addition & 0 deletions packages/expo-notifications/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

### 🐛 Bug fixes

- [iOS] Fix a data race on `NotificationCenterManager`'s delegate list that crashed the app with `SIGSEGV` when one app context registered its modules while another tore its own down, such as on a dev-client reload or `Updates.reloadAsync()`. [#49554](https://github.com/expo/expo/pull/49554) by [@dennytosp](https://github.com/dennytosp))
- [Android] Prevented `onUserLeaveHint` from firing when a notification tap opens the app, which made picture-in-picture implementations enter PiP unexpectedly. ([#48471](https://github.com/expo/expo/pull/48471) by [@stareezy-1](https://github.com/stareezy-1))
- [iOS] Avoid warning when an aborted push token registration request rejects with a native fetch cancellation error. ([#48547](https://github.com/expo/expo/pull/48547) by [@JoaoPauloCMarra](https://github.com/JoaoPauloCMarra))
- [Android] Prevent a crash on notification tap when `getLaunchIntentForPackage` throws on some OEM ROMs. ([#47889](https://github.com/expo/expo/pull/47889) by [@nunocaseiro](https://github.com/nunocaseiro))
Expand Down
4 changes: 4 additions & 0 deletions packages/expo-notifications/ios/ExpoNotifications.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,9 @@ Pod::Spec.new do |s|
test_spec.dependency 'ExpoModulesTestCore'

test_spec.source_files = "Tests/**/*.{m,mm,swift}"

test_spec.pod_target_xcconfig = {
'OTHER_LDFLAGS' => '$(inherited) -lc++'
}
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,59 @@ public extension NotificationDelegate {
*/
private let discardPresentationOptions: (UNNotificationPresentationOptions) -> Void = { _ in }

/**
The delegates of `NotificationCenterManager`, and the responses that arrived before any delegate
could handle them.

The manager is a process-wide singleton, but app contexts are not: the modules of an incoming
context register themselves while an outgoing context tears its own down, which happens on every
dev-client reload and on `Updates.reloadAsync()`. Both arrays are therefore written from more than
one thread at a time, so they live behind a lock.
*/
internal final class NotificationDelegateRegistry {
private let state = Mutex(State())

private struct State {
var delegates: [NotificationDelegate] = []
var pendingResponses: [UNNotificationResponse] = []
}

/**
A snapshot of the delegates. Callers iterate the snapshot instead of holding the lock across a
delegate callback: `add` hands back the pending responses so that the caller can offer them to
the delegate it just added, and a delegate is free to add or remove delegates from there.
*/
var delegates: [NotificationDelegate] {
state.withLock { $0.delegates }
}

var pendingResponses: [UNNotificationResponse] {
state.withLock { $0.pendingResponses }
}

/**
Adds the delegate, and returns the responses that it still has to be offered.
*/
func add(_ delegate: NotificationDelegate) -> [UNNotificationResponse] {
state.withLock { state in
state.delegates.append(delegate)
return state.pendingResponses
}
}

func remove(_ delegate: AnyObject) {
state.withLock { $0.delegates.removeAll { $0 === delegate } }
}

func appendPendingResponse(_ response: UNNotificationResponse) {
state.withLock { $0.pendingResponses.append(response) }
}

func removeAllPendingResponses() {
state.withLock { $0.pendingResponses.removeAll() }
}
}

/**
Singleton that sets itself as the UserNotificationCenter delegate,
and calls its own delegates in response to notification center calls.
Expand All @@ -50,8 +103,15 @@ public class NotificationCenterManager: NSObject,
@objc
public static let shared = NotificationCenterManager()

var delegates: [NotificationDelegate] = []
var pendingResponses: [UNNotificationResponse] = []
private let registry = NotificationDelegateRegistry()

var delegates: [NotificationDelegate] {
registry.delegates
}

var pendingResponses: [UNNotificationResponse] {
registry.pendingResponses
}

/**
Delegate of the notification center that another library set before us. We forward every
Expand Down Expand Up @@ -102,18 +162,17 @@ public class NotificationCenterManager: NSObject,
}

public func addDelegate(_ delegate: NotificationDelegate) {
delegates.append(delegate)
var handled = false
for pendingResponse in pendingResponses {
for pendingResponse in registry.add(delegate) {
handled = delegate.didReceive(pendingResponse, completionHandler: {}) || handled
}
if handled {
pendingResponses.removeAll()
registry.removeAllPendingResponses()
}
}

public func removeDelegate(_ delegate: AnyObject) {
delegates.removeAll { $0 === delegate }
registry.remove(delegate)
}

// MARK: - Called by PushTokenAppDelegateSubscriber
Expand Down Expand Up @@ -173,7 +232,7 @@ public class NotificationCenterManager: NSObject,
handled = delegate.didReceive(response, completionHandler: completionHandler) || handled
}
if !handled {
pendingResponses.append(response)
registry.appendPendingResponse(response)
}
chainedDelegate?.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler)
completionHandler()
Expand Down
Loading
Loading