From ea1858d00ec5465557893420b671c65cc5bbca68 Mon Sep 17 00:00:00 2001 From: Oleg Bezrukavnikov Date: Wed, 26 Aug 2026 03:18:56 -0700 Subject: [PATCH 01/17] [image-picker][ios] Don't read the photo library on the video fast path without read access (#49362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why Picking a video with `launchImageLibraryAsync` terminates apps that ship no `NSPhotoLibraryUsageDescription` in their `Info.plist`. `PHPickerViewController` requires **no** photo library authorization — that is its whole design purpose: the user picks out-of-process and the app receives only the chosen items. `expo-image-picker` builds its picker with `PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())`, which is fine on its own: it just makes the results Photos-backed so `assetIdentifier` is populated. However, the passthrough fast path added in #37569 calls `PHAsset.fetchAssets(withLocalIdentifiers:)`. That is a photo library **read**, so it trips a `kTCCServicePhotos` authorization check. For an app that ships no `NSPhotoLibraryUsageDescription` — perfectly legal, and the recommended configuration for an app that only uses the system picker — that check does not merely fail: iOS terminates the app. `tccd` logs: ``` Refusing authorization request for service kTCCServicePhotos ... without NSPhotoLibraryUsageDescription key ``` The termination happens after the user taps "Done" in the picker, so it looks like a picker bug rather than a permission bug, and no crash report is written to `DiagnosticReports`, which makes it hard to diagnose. This is a regression: before #37569, the video path used only `loadFileRepresentation` on the item provider, which needs no authorization. The effect today is that `expo-image-picker` forces every app that picks videos to request full read access to the photo library, purely to keep an optimization that only matters for *adjusted* assets — even though the config plugin exposes `photosPermission: false` for exactly the case of an app that doesn't want that permission. # How Only take the fast path when the app actually holds the read access that path requires: ```swift let photoLibraryReadStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) let hasPhotoLibraryReadAccess = photoLibraryReadStatus == .authorized || photoLibraryReadStatus == .limited if options.videoExportPreset == .passthrough, hasPhotoLibraryReadAccess, let assetId = selectedVideo.assetIdentifier { ``` - Apps holding read access keep the optimization unchanged. - Apps without it fall through to the pre-existing `loadVideoRepresentation` path — the behavior before the fast path was introduced. That path still preserves the original bytes under `.passthrough`, and still returns `selectedVideo.assetIdentifier` as `assetId`, so the resolved asset is unchanged apart from timing. - `PHPhotoLibrary.authorizationStatus(for:)` does not prompt and is safe to call without a usage-description key. `.limited` is included because in limited mode `fetchAssets` legitimately succeeds for assets in the user's granted set, and gracefully returns empty (falling through) for ones outside it — so the optimization is preserved there too. # Test Plan I do not have a self-contained repro app to attach; the following was verified downstream by the reporter in a real Expo/React Native app (NavigateAI), against `expo-image-picker@57.0.8` patched with exactly this change, on an iOS 18.6 simulator: - With all photo permissions revoked and no `NSPhotoLibraryUsageDescription` in the built `Info.plist`, picking a video from the library previously terminated the app on "Done". With this change the pick completes and resolves an asset whose `assetId` is populated. - With full read access granted, the fast path is still taken and behavior is unchanged. I have not run the expo repo's own test-suites for this package beyond confirming the change is a compile-level no-op for callers (no public API change, no JS/TS change). # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) — changelog entry added; there is no JS/TS source to rebuild, this is an iOS-only change. - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). — not applicable, no config plugin change. - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) — not applicable, no docs change. --------- Co-authored-by: Vojtech Novak --- packages/expo-image-picker/CHANGELOG.md | 1 + packages/expo-image-picker/ios/MediaHandler.swift | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/expo-image-picker/CHANGELOG.md b/packages/expo-image-picker/CHANGELOG.md index ce49c6a035a224..53e522d4b23964 100644 --- a/packages/expo-image-picker/CHANGELOG.md +++ b/packages/expo-image-picker/CHANGELOG.md @@ -17,6 +17,7 @@ - [iOS] Fixed crop square detection using the main screen's scale instead of the scale of the view being measured. ([#48172](https://github.com/expo/expo/pull/48172) by [@alanjhughes](https://github.com/alanjhughes)) - [iOS] Fix a failed crop resolving with the uncropped original image instead of rejecting when `allowsEditing` is `true`. ([#48524](https://github.com/expo/expo/issues/48524) by [@aashishshrestha5532](https://github.com/aashishshrestha5532), [#48541](https://github.com/expo/expo/pull/48541) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway)) - [iOS] Fix the video `PHAssetResourceManager` fast path rejecting instead of falling back to the slower path that can fetch iCloud assets, and pass `shouldDownloadFromNetwork` through when reading Live Photo resources. As a result, a video whose data is not stored locally is now downloaded through the fallback path even when `shouldDownloadFromNetwork` is `false` and `videoExportPreset` is `Passthrough`. ([#48658](https://github.com/expo/expo/issues/48658) by [@gcampoyf-cloud](https://github.com/gcampoyf-cloud), [#48794](https://github.com/expo/expo/pull/48794) by [@expo-bot](https://github.com/expo-bot)) +- [iOS] Fix picking a video terminating apps that ship no `NSPhotoLibraryUsageDescription` by only taking the `PHAssetResourceManager` fast path when photo library read access has already been granted. ([#49362](https://github.com/expo/expo/pull/49362) by [@OlegBezr](https://github.com/OlegBezr)) ### 💡 Others diff --git a/packages/expo-image-picker/ios/MediaHandler.swift b/packages/expo-image-picker/ios/MediaHandler.swift index b83260ca46ab52..1e64460ea1a313 100644 --- a/packages/expo-image-picker/ios/MediaHandler.swift +++ b/packages/expo-image-picker/ios/MediaHandler.swift @@ -420,7 +420,10 @@ internal struct MediaHandler { // asset as *adjusted* and will re-render a temporary file for us. Copying the resource bytes // ourselves is dramatically faster because it just streams the already-existing file. - if options.videoExportPreset == .passthrough, let assetId = selectedVideo.assetIdentifier { + let photoLibraryReadStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) + let hasPhotoLibraryReadAccess = photoLibraryReadStatus == .authorized || photoLibraryReadStatus == .limited + + if options.videoExportPreset == .passthrough, hasPhotoLibraryReadAccess, let assetId = selectedVideo.assetIdentifier { let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil) if let asset = fetchResult.firstObject { // Prefer the full-size resource when available, otherwise fall back to the default `.video`. From afbe5b313fb1453986b80148d7e25556cee8bf24 Mon Sep 17 00:00:00 2001 From: Aman Mittal Date: Wed, 26 Aug 2026 15:49:06 +0530 Subject: [PATCH 02/17] [docs] Add a section and reference to audit logs command on audit logs page (#49350) # Why Fix ENG-26175 # How Add a section and reference to audit logs command on audit logs page. # Test Plan CleanShot 2026-08-25 at 22 43 10@2x # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- docs/pages/accounts/audit-logs.mdx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/pages/accounts/audit-logs.mdx b/docs/pages/accounts/audit-logs.mdx index 8608c3db142a71..060966d743f962 100644 --- a/docs/pages/accounts/audit-logs.mdx +++ b/docs/pages/accounts/audit-logs.mdx @@ -4,6 +4,7 @@ description: Learn how to track and analyze your account's activities by using t --- import { ContentSpotlight } from '~/ui/components/ContentSpotlight'; +import { Terminal } from '~/ui/components/Snippet'; > **info** Audit logs are available for [Enterprise plan](https://expo.dev/pricing) customers. @@ -14,7 +15,7 @@ Audit logs record actions made with Expo Application Services (EAS) by accounts. - Audit logs can only be created and never modified or deleted, they serve as a source of truth to help monitor events and debug issues occurring within accounts. - **Audit logs are available to Enterprise plan customers**. When subscribed, some of the logs used internally by Expo are immediately available, while other types of logs are starting to be collected after the subscription is activated. - Audit logs are stored for 1.5 years. If an account is deleted, its audit logs will be deleted after 90 days. -- To access them, go to **Account settings**/**Organization settings** > [**Audit logs**](https://expo.dev/accounts/[account]/settings/audit-logs). +- To access them, go to **Account settings**/**Organization settings** > [**Audit logs**](https://expo.dev/accounts/[account]/settings/audit-logs), or use [EAS CLI](#view-audit-logs-with-eas-cli). **Note:** Export is currently only available through the Expo website. There is no API available for programmatic export of audit logs. +> **Note:** The **Export** button is only available on the Expo website. To read audit logs programmatically, use [EAS CLI](#view-audit-logs-with-eas-cli). + +## View audit logs with EAS CLI + +You can also programmatically read audit logs from a terminal. Run the `eas account:audit` command with the account name: + + + +If you omit the account name, the command prompts you to select one. To read the logs in a script, pass the `--json` flag: + + + +For pagination and the remaining flags, see the [`eas account:audit`](/eas/cli/#eas-account-audit-account-name) reference. From af721da80063522b84c15fef685a0155311bac0e Mon Sep 17 00:00:00 2001 From: Aman Mittal Date: Wed, 26 Aug 2026 15:49:16 +0530 Subject: [PATCH 03/17] [docs] Remove unused EAS Hosting shoutout banner (#49349) # Why Fix ENG-26176 # How Remove unused EAS Hosting shoutout banner. # Test Plan N/A # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- .../components/EASHostingShoutoutBanner.tsx | 110 ------------------ 1 file changed, 110 deletions(-) delete mode 100644 docs/ui/components/EASHostingShoutoutBanner.tsx diff --git a/docs/ui/components/EASHostingShoutoutBanner.tsx b/docs/ui/components/EASHostingShoutoutBanner.tsx deleted file mode 100644 index 75be5e25b123ac..00000000000000 --- a/docs/ui/components/EASHostingShoutoutBanner.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Button, mergeClasses } from '@expo/styleguide'; -import { ArrowUpRightIcon } from '@expo/styleguide-icons/outline/ArrowUpRightIcon'; -import { Cloud01Icon } from '@expo/styleguide-icons/outline/Cloud01Icon'; -import { XIcon } from '@expo/styleguide-icons/outline/XIcon'; -import { useEffect, useState } from 'react'; - -import { useLocalStorage } from '~/common/useLocalStorage'; - -export function EASHostingShoutoutBanner() { - const [isLoaded, setIsLoaded] = useState(false); - const [lastDismissDate, setLastDismissDate] = useLocalStorage({ - name: 'eas-hosting-shoutout', - defaultValue: null, - }); - - useEffect(function didMount() { - setIsLoaded(true); - }, []); - - if (lastDismissDate || !isLoaded) { - return null; - } - - return ( -
- -
-
-
-
-
-

EAS Hosting

-

- Try the first end-to-end deployment solution for universal app development. -

-
-
-
- -
-
- ); -} From bde9b918e1614d69768c1f65a419f1c90a1272f5 Mon Sep 17 00:00:00 2001 From: Aman Mittal Date: Wed, 26 Aug 2026 15:49:36 +0530 Subject: [PATCH 04/17] [docs] Pick light and dark images in CSS (#49347) # Why Fix ENG-26172 # How - Pick light and dark images in CSS instead of JavaScript in all image based components. When images are picked by JavaScript, they happen after the first hydration which is why, previously, there was a frame where light mode images were picked first before dark mode images when dark mode theme was selected. - Remove unused `prefersDarkTheme` code. # Test Plan **Examples after fix of the image loading:** https://github.com/user-attachments/assets/a2ed08fd-0e54-467b-81c5-261414f8184f https://github.com/user-attachments/assets/f14ada33-d192-4d85-a945-5430c4665912 **How to test manually:** - Go to https://pr-49347.expo-docs.pages.dev/eas/observe/introduction/ - Either switch to dark mode theme - Hard refresh the page and ensure that light mode image doesn't load # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- docs/common/window.ts | 8 +- .../set-up-your-environment/SelectCard.tsx | 32 +++++--- .../TemplateFeatures/Content.tsx | 32 ++++---- docs/tailwind.config.cjs | 80 +++++++++--------- docs/ui/components/ComponentExample.tsx | 20 +---- .../ContentSpotlight/LightboxImage.tsx | 37 +++++++-- docs/ui/components/ContentSpotlight/index.tsx | 27 +++---- docs/ui/components/Diagram/Diagram.tsx | 81 +++++++++---------- 8 files changed, 169 insertions(+), 148 deletions(-) diff --git a/docs/common/window.ts b/docs/common/window.ts index 58f731fd51eeda..5b40f34afeeed0 100644 --- a/docs/common/window.ts +++ b/docs/common/window.ts @@ -14,10 +14,10 @@ export function getViewportSize() { }; } -export function prefersDarkTheme() { - return window?.matchMedia('(prefers-color-scheme: dark)').matches ?? false; -} - export function prefersReducedMotion() { return window?.matchMedia('(prefers-reduced-motion)').matches ?? false; } + +export function isDarkTheme() { + return document.documentElement.classList.contains('dark-theme'); +} diff --git a/docs/scenes/get-started/set-up-your-environment/SelectCard.tsx b/docs/scenes/get-started/set-up-your-environment/SelectCard.tsx index f9aab6c2b9f542..c0594bc9c9ca62 100644 --- a/docs/scenes/get-started/set-up-your-environment/SelectCard.tsx +++ b/docs/scenes/get-started/set-up-your-environment/SelectCard.tsx @@ -1,4 +1,4 @@ -import { ButtonBase, mergeClasses, useTheme } from '@expo/styleguide'; +import { ButtonBase, mergeClasses } from '@expo/styleguide'; import { CALLOUT, HEADLINE } from '~/ui/components/Text'; @@ -21,7 +21,7 @@ export function SelectCard({ isSelected, onClick, }: Props) { - const { themeName } = useTheme(); + const imageClasses = isSelected ? 'grayscale-0' : 'opacity-80 grayscale'; return ( @@ -35,20 +35,30 @@ export function SelectCard({ 'border-b border-default', isSelected ? 'bg-linear-to-b from-palette-blue3 to-palette-blue4' : 'bg-subtle' )}> - - {themeName !== 'light' && ( - - )} + {alt} + {darkImgSrc && ( + + {alt} + + )}
diff --git a/docs/scenes/get-started/start-developing/TemplateFeatures/Content.tsx b/docs/scenes/get-started/start-developing/TemplateFeatures/Content.tsx index 1f68c8fc35afb1..44a4027d727392 100644 --- a/docs/scenes/get-started/start-developing/TemplateFeatures/Content.tsx +++ b/docs/scenes/get-started/start-developing/TemplateFeatures/Content.tsx @@ -1,8 +1,6 @@ -import { Button, useTheme } from '@expo/styleguide'; +import { Button, mergeClasses } from '@expo/styleguide'; import { ArrowRightIcon } from '@expo/styleguide-icons/outline/ArrowRightIcon'; -import { ReactNode, useEffect, useState } from 'react'; - -import { prefersDarkTheme } from '~/common/window'; +import { ReactNode } from 'react'; type Props = { imgSrc: string; @@ -13,23 +11,23 @@ type Props = { }; export function Content({ imgSrc, darkImgSrc, alt, href, content }: Props) { - const { themeName } = useTheme(); - const [isDarkMode, setIsDarkMode] = useState(false); - - useEffect( - function didMount() { - setIsDarkMode(themeName === 'dark' || (themeName === 'auto' && prefersDarkTheme())); - }, - [themeName] - ); - return (
- - {isDarkMode && } - {alt} + + {alt} + {darkImgSrc && ( + + {alt} + + )}
diff --git a/docs/tailwind.config.cjs b/docs/tailwind.config.cjs index 9bdc85d4561305..7ae1f2c92b522a 100644 --- a/docs/tailwind.config.cjs +++ b/docs/tailwind.config.cjs @@ -1,5 +1,10 @@ const expoTheme = require('@expo/styleguide/tailwind'); const merge = require('lodash/merge'); +const plugin = require('tailwindcss/plugin'); + +const lightVariant = plugin(({ addVariant }) => { + addVariant('light', '&:is(:root:not([class*="dark-theme"]) *)'); +}); function getExpoTheme(extend = {}, plugins = []) { const customizedTheme = Object.assign({}, expoTheme); @@ -21,45 +26,48 @@ module.exports = { './node_modules/@expo/styleguide-search-ui/dist/**/*.{js,ts,jsx,tsx}', './node_modules/@expo/styleguide-cookie-consent/dist/**/*.{js,ts,jsx,tsx}', ], - ...getExpoTheme({ - screens: { - sm: '468px', - md: '788px', - lg: '1008px', - xl: '1328px', - '2xl': '1572px', - }, - borderColor: { - 'palette-orange3.5': 'hsl(from var(--orange-4) h calc(s - 5) calc(l + 5));', - }, - backgroundImage: { - 'cell-quickstart-pattern': "url('/static/images/home/QuickStartPattern.svg')", - 'cell-tutorial-pattern': "url('/static/images/home/TutorialPattern.svg')", - 'cell-workflows-pattern': "url('/static/images/home/WorkflowsPattern.svg')", - }, - keyframes: { - wave: { - '0%, 100%': { - transform: 'rotate(0deg)', - }, - '50%': { - transform: 'rotate(20deg)', - }, + ...getExpoTheme( + { + screens: { + sm: '468px', + md: '788px', + lg: '1008px', + xl: '1328px', + '2xl': '1572px', + }, + borderColor: { + 'palette-orange3.5': 'hsl(from var(--orange-4) h calc(s - 5) calc(l + 5));', + }, + backgroundImage: { + 'cell-quickstart-pattern': "url('/static/images/home/QuickStartPattern.svg')", + 'cell-tutorial-pattern': "url('/static/images/home/TutorialPattern.svg')", + 'cell-workflows-pattern': "url('/static/images/home/WorkflowsPattern.svg')", }, - slideUpAndFadeIn: { - '0%': { - opacity: 0, - transform: 'translateY(16px)', + keyframes: { + wave: { + '0%, 100%': { + transform: 'rotate(0deg)', + }, + '50%': { + transform: 'rotate(20deg)', + }, }, - '100%': { - opacity: 1, - transform: 'translateY(0)', + slideUpAndFadeIn: { + '0%': { + opacity: 0, + transform: 'translateY(16px)', + }, + '100%': { + opacity: 1, + transform: 'translateY(0)', + }, }, }, + animation: { + slideUpAndFadeIn: 'slideUpAndFadeIn 0.25s ease-out', + wave: 'wave 0.25s ease-in-out 4', + }, }, - animation: { - slideUpAndFadeIn: 'slideUpAndFadeIn 0.25s ease-out', - wave: 'wave 0.25s ease-in-out 4', - }, - }), + [lightVariant] + ), }; diff --git a/docs/ui/components/ComponentExample.tsx b/docs/ui/components/ComponentExample.tsx index 1c13d9deac1881..ba3a3ed75d76e4 100644 --- a/docs/ui/components/ComponentExample.tsx +++ b/docs/ui/components/ComponentExample.tsx @@ -1,9 +1,7 @@ -import { useTheme } from '@expo/styleguide'; import { FileCode01Icon } from '@expo/styleguide-icons/outline/FileCode01Icon'; -import { PropsWithChildren, useEffect, useState } from 'react'; +import { PropsWithChildren } from 'react'; import { cleanCopyValue, getCodeBlockDataFromChildren } from '~/common/code-utilities'; -import { prefersDarkTheme } from '~/common/window'; import { usePageApiVersion } from '~/providers/page-api-version'; import { LightboxImage } from '~/ui/components/ContentSpotlight/LightboxImage'; import { PlatformTabs } from '~/ui/components/PlatformTabs'; @@ -89,25 +87,14 @@ function resolveImages({ * every capture carries as `-ios-` or `-android-`, so pages need no extra prop. */ export function ComponentExample({ title, src, darkSrc, alt, android, ios, children }: Props) { - const { themeName } = useTheme(); const context = usePageApiVersion(); - const [isDark, setDark] = useState(false); const images = resolveImages({ src, darkSrc, alt, android, ios }); const available = orderPlatforms(images); const { active, select } = usePlatformSelection(available); - useEffect(() => { - if (themeName === 'auto') { - setDark(prefersDarkTheme()); - } else { - setDark(themeName === 'dark'); - } - }, [themeName]); - const { value } = getCodeBlockDataFromChildren(children); const image = images[active]; - const activeSrc = image && isDark && image.darkSrc ? image.darkSrc : image?.src; const device = active === 'android' ? DEVICE_FRAMES.android : DEVICE_FRAMES.ios; return ( @@ -120,7 +107,7 @@ export function ComponentExample({ title, src, darkSrc, alt, android, ios, child {children}
- {image && activeSrc && ( + {image && (
import('./LightboxModal'), { ssr: false }); type Props = ImgHTMLAttributes & { src: string; + darkSrc?: string; }; -export function LightboxImage({ src, alt, ...rest }: Props) { +export function LightboxImage({ src, darkSrc, alt, className, ...rest }: Props) { const [open, setOpen] = useState(false); - const [lightboxRequested, setLightboxRequested] = useState(false); + const [lightboxSrc, setLightboxSrc] = useState(); return ( <> - {lightboxRequested && ( + {lightboxSrc && ( { setOpen(false); diff --git a/docs/ui/components/ContentSpotlight/index.tsx b/docs/ui/components/ContentSpotlight/index.tsx index 84d517c3f5a8f0..a36e4578a7d8c5 100644 --- a/docs/ui/components/ContentSpotlight/index.tsx +++ b/docs/ui/components/ContentSpotlight/index.tsx @@ -1,9 +1,8 @@ -import { mergeClasses, useTheme } from '@expo/styleguide'; +import { mergeClasses } from '@expo/styleguide'; import { useInView } from 'framer-motion'; import dynamic from 'next/dynamic'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; -import { prefersDarkTheme } from '~/common/window'; import { DotGrid } from '~/ui/components/Diagram/DotGrid'; import { LightboxImage } from './LightboxImage'; @@ -61,16 +60,6 @@ export function ContentSpotlight({ aspect, }: ContentSpotlightProps) { const [forceShowControls, setForceShowControls] = useState(); - const { themeName } = useTheme(); - const [isDark, setDark] = useState(themeName === 'dark'); - - useEffect(() => { - if (themeName === 'auto') { - setDark(prefersDarkTheme()); - } else { - setDark(themeName === 'dark'); - } - }, [themeName]); const resolvedPlayerWidth = playerWidth ?? PLAYER_WIDTH; const resolvedPlayerHeight = playerHeight ?? PLAYER_HEIGHT; @@ -87,7 +76,6 @@ export function ContentSpotlight({ const shouldAutoplay = isInView && isVideo && (!videoId || autoplayYT); const isComponentVariant = variant === 'component' && !isVideo; - const activeSrc = isDark && darkSrc ? darkSrc : src; return (
- - {isDark && darkSrc && } + {alt} + {darkSrc && ( + + {alt} + + )} ) : src ? ( { - const { themeName } = useTheme(); - const [isDark, setDark] = useState(themeName === 'dark'); +type PictureProps = { + src: string; + alt: string; + withFormats: boolean; + isPaired: boolean; + className: string; +}; - useEffect(() => { - if (themeName === 'auto') { - setDark(prefersDarkTheme()); - } else { - setDark(themeName === 'dark'); - } - }, [themeName]); +function DiagramPicture({ src, alt, withFormats, isPaired, className }: PictureProps) { + return ( + + {withFormats && } + {withFormats && } + {alt} + + ); +} - if (!source.endsWith('.png')) { - return ( -
- - - {isDark && darkSource && } - {alt} - -
- ); - } +export const Diagram = ({ source, darkSource, disableSrcSet, alt }: Props) => { + const withFormats = source.endsWith('.png') && !disableSrcSet; return (
- - {!isDark && !disableSrcSet && ( - - )} - {darkSource && isDark && !disableSrcSet && ( - - )} - {!isDark && !disableSrcSet && ( - - )} - {darkSource && isDark && !disableSrcSet && ( - - )} - {darkSource && isDark && } - {alt} - + + {darkSource && ( + + )}
); }; From 89afd1c59073324b7dca998fbb66b9ebbe4353bb Mon Sep 17 00:00:00 2001 From: Ryan Saffer <36721381+ryan-saffer@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:41:05 +1000 Subject: [PATCH 05/17] [screen-orientation]: avoid main thread deadlock (#49367) # Why Fixes https://github.com/expo/expo/issues/49365. closes #44276 On iOS, ScreenOrientationRegistry can enter a circular wait that permanently blocks the main thread: 1. A controller notification runs on expo.screenorientationregistry. 2. ScreenOrientationModule.screenOrientationDidChange reads currentOrientationMask, which synchronously waits for the main thread. 3. UIKit queries supportedInterfaceOrientations on the main thread. 4. This reaches requiredOrientationMask(), which synchronously waits for expo.screenorientationregistry. The registry queue and main thread then wait for each other, causing an app hang. We observed this frequently in production after upgrading from expo-screen-orientation 9.0.8 to 57.0.1. # How Controller notifications are now delivered on a dedicated notification queue rather than the registry's state-protection queue. screenOrientationDidChange snapshots the registered controllers while holding a barrier on the registry queue, then releases that queue before notifying them. This prevents controller callbacks that synchronously access the main thread from blocking registry state reads made by the main thread. Controller registration and removal now also use barrier writes, ensuring mutations cannot race with the controller snapshot. # Test Plan Test Plan The issue includes a minimal iOS development-build reproduction that deterministically arranges the production wait cycle. 1. Clone the reproduction linked in https://github.com/expo/expo/issues/49365. 2. Run npm install. 3. Run npm run ios. 4. Wait three seconds or press Trigger now. 5. Without this fix, the app remains on TESTING... and stops responding. 6. Pause the process in Xcode and inspect the main thread. It is blocked in ScreenOrientationRegistry.requiredOrientationMask(). 7. Apply this change to expo-screen-orientation and rebuild the native app. 8. The status changes from TESTING... to RESPONSIVE, and the app continues handling touches. The equivalent change was also tested locally as a patch-package patch against expo-screen-orientation@57.0.1. The reproduction remained responsive after the deterministic trigger. Expo Doctor output for the reproduction: Running 21 checks on your project... 21/21 checks passed. No issues detected! # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --------- Co-authored-by: Vojtech Novak --- packages/expo-screen-orientation/CHANGELOG.md | 1 + .../ios/ScreenOrientationRegistry.swift | 13 +++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/expo-screen-orientation/CHANGELOG.md b/packages/expo-screen-orientation/CHANGELOG.md index fa0c378b9e7420..0037b4ec9cf651 100644 --- a/packages/expo-screen-orientation/CHANGELOG.md +++ b/packages/expo-screen-orientation/CHANGELOG.md @@ -8,6 +8,7 @@ ### 🐛 Bug fixes +- [iOS] Fixed a main-thread deadlock when notifying screen orientation listeners. ([#49367](https://github.com/expo/expo/pull/49367) by [@ryan-saffer](https://github.com/ryan-saffer)) - [iOS] Added a development warning when the system refuses an orientation lock request, and switched to reading interface orientation from the scene's effective geometry. ([#48173](https://github.com/expo/expo/pull/48173) by [@alanjhughes](https://github.com/alanjhughes)) ### 💡 Others diff --git a/packages/expo-screen-orientation/ios/ScreenOrientationRegistry.swift b/packages/expo-screen-orientation/ios/ScreenOrientationRegistry.swift index e5b8d1957d4d14..a1411513c03633 100644 --- a/packages/expo-screen-orientation/ios/ScreenOrientationRegistry.swift +++ b/packages/expo-screen-orientation/ios/ScreenOrientationRegistry.swift @@ -19,6 +19,7 @@ public class ScreenOrientationRegistry: NSObject, UIApplicationDelegate { var orientationControllers: [ScreenOrientationController] = [] var controllerInterfaceMasks: [ObjectIdentifier: UIInterfaceOrientationMask] = [:] private let queue = DispatchQueue(label: "expo.screenorientationregistry", attributes: .concurrent) + private let notificationQueue = DispatchQueue(label: "expo.screenorientationregistry.notifications") @objc public var currentTraitCollection: UITraitCollection? var lastOrientationMask: UIInterfaceOrientationMask @@ -204,23 +205,23 @@ public class ScreenOrientationRegistry: NSObject, UIApplicationDelegate { Called at the end of the screen orientation change. Notifies the controllers about the orientation change. */ func screenOrientationDidChange(_ newScreenOrientation: UIInterfaceOrientation) { - queue.sync(flags: .barrier) { + let controllers = queue.sync(flags: .barrier) { // Write with the barrier: if self.currentScreenOrientation != newScreenOrientation { // Only change if necessary, to prevent listeners from re-calling this method. self.currentScreenOrientation = newScreenOrientation } + return self.orientationControllers } - queue.async { - // Read without the barrier: - for controller in self.orientationControllers { + notificationQueue.async { + for controller in controllers { controller.screenOrientationDidChange(newScreenOrientation) } } } public func registerController(_ controller: ScreenOrientationController) { - queue.sync { + queue.sync(flags: .barrier) { self.orientationControllers.append(controller) } } @@ -228,7 +229,7 @@ public class ScreenOrientationRegistry: NSObject, UIApplicationDelegate { public func unregisterController(_ controller: ScreenOrientationController) { let controllerIdentifier = ObjectIdentifier(controller) - queue.sync { + queue.sync(flags: .barrier) { self.controllerInterfaceMasks.removeValue(forKey: controllerIdentifier) self.orientationControllers.removeAll(where: { $0 === controller }) } From 6d57b2e95f97578c616f0c738368ea5aeea42022 Mon Sep 17 00:00:00 2001 From: Aman Mittal Date: Wed, 26 Aug 2026 16:22:34 +0530 Subject: [PATCH 06/17] [docs] Replace `lodash/partition` with a local helper (#49388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why Replaces the single `lodash/partition` call in `common/code-utilities.ts` with a six-line local helper. It was the only lodash import that reaches the browser bundle, and `_app` loads on every page. # How Saves ~7 KB transferred per page (measured on `/versions/latest/sdk/calendar/`: 1363 KB → 1356 KB, median of 5 local Lighthouse runs). # Test Plan N/A # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- docs/common/code-utilities.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/common/code-utilities.ts b/docs/common/code-utilities.ts index fcceabff3bb982..46fafbbb25bae0 100644 --- a/docs/common/code-utilities.ts +++ b/docs/common/code-utilities.ts @@ -1,4 +1,3 @@ -import partition from 'lodash/partition'; import { Language, Prism } from 'prism-react-renderer'; import { Children, ReactElement, ReactNode, PropsWithChildren, isValidElement } from 'react'; @@ -208,6 +207,15 @@ export function replaceSlashCommentsWithAnnotationsForTutorial(value: string) { ); } +function partition(items: T[], predicate: (item: T) => boolean): [T[], T[]] { + const matched: T[] = []; + const rest: T[] = []; + for (const item of items) { + (predicate(item) ? matched : rest).push(item); + } + return [matched, rest]; +} + export function parseValue(value: string) { if (value.startsWith('@@@')) { const valueChunks = value.split('@@@'); From e5271fa64f0e5c522eccc55055f1d1f150e1f7e4 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 26 Aug 2026 12:06:59 +0100 Subject: [PATCH 07/17] [expo-file-system][android] Resolve upload content length once instead of per 8 KiB segment (#49206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why On Android, uploading a file picked through the Storage Access Framework with `File.upload()` is around 100x slower than it should be. `CountingSink.write` calls `requestBody.contentLength()` on every write to report progress, and okio hands it one 8 KiB segment at a time. For a `content://` URI the request body's `contentLength()` is `SAFDocumentFile.length()`, which is a `ContentResolver` query into the providing app. So the cost of an upload is dominated by one binder round trip per 8 KiB, not by the transfer itself. Measured with `File.upload()` on a 294 MB document picked from the system picker, on an API 29 emulator: 94.4 s before, 0.86 s after (numbers and method below). `MULTIPART` was never affected — `MultipartBody` caches its own `contentLength()` — so this is `BINARY_CONTENT` only, which is the default. iOS is unaffected: it uploads via `URLSession.uploadTask(fromFile:)`. # How `contentLength()` is constant for the lifetime of a `RequestBody`, so there is no reason to re-query it: - `CountingRequestBody` resolves the delegate's length once into a `lazy` field, and passes that `Long` to `CountingSink` instead of passing the request body and letting the sink call back into it. `CountingSink` no longer holds a `RequestBody` at all, which makes its dependency on a constant length explicit rather than incidental. - The `UnifiedFileInterface.asRequestBody` body resolves `length()` once, also into a `lazy` field, for the same reason — that call is the SAF query itself. Keeping it lazy rather than eager matters: `ContentProviderFile.length()` and `AssetFile.length()` can fall back to reading the whole stream, and this way that work still happens on the OkHttp thread when OkHttp asks for the header, not on the caller's queue while the request is being built. No public API changes. Progress callbacks report the same values. # Test Plan Measured on device, on this branch versus its merge base, with the `expo-file-system` Android sources built from source so the Kotlin under test is the one in this diff. **Correction to the original description:** the numbers in the first version of this PR were produced by `File.upload()`, not by `FileSystem.uploadAsync`. Thanks to @expo-bot for catching the wrong API name — the legacy call converts its URI to a `java.io.File` and never reaches this file, so it could not have exercised the change. Everything below is a fresh measurement through `File.upload()`. **Setup.** Pixel 7a AVD, API 29, arm64. `apps/minimal-tester`, built with `npx expo run:android --variant release` on each arm. A 294 MB file (293,940,775 bytes) pushed to `/sdcard/Download` and picked with `File.pickFileAsync()`, which yields `content://com.android.providers.downloads.documents/document/msf%3A26` and so resolves to `SAFDocumentFile`. A Node HTTP sink on the host counts the bytes it receives and discards them, reached over `adb reverse tcp:8099 tcp:8099`. The app calls: ```ts const result = await file.upload('http://127.0.0.1:8099/upload', { httpMethod: 'PUT', uploadType, // 0 = BINARY_CONTENT, 1 = MULTIPART headers: { 'Content-Type': 'application/octet-stream' }, onProgress: ({ bytesSent, totalBytes }) => { /* count events, collect distinct totals */ }, }); ``` **Results.** Each row is the wall clock of `file.upload()` measured in JS, three consecutive runs where three are given. | source | upload type | before | after | |---|---|---|---| | SAF `content://` | `BINARY_CONTENT` | 91.8 / 94.4 / 105.5 s | 1.02 / 0.85 / 0.86 s | | SAF `content://` | `MULTIPART` | 1.02 s | 0.64 s | | `file://` (same file copied into the cache dir) | `BINARY_CONTENT` | 1.24 / 0.98 s | 1.04 / 0.78 s | **Correctness, identical in both arms and in every run:** the sink received exactly 293,940,775 bytes and the request declared the same `Content-Length`; the last progress event was `(293940775, 293940775)`; and the progress stream carried exactly one distinct `totalBytes` value. The event *count* drops from ~900–1000 to ~10 only because `emitProgress` throttles to one event per 100 ms and the upload is now two orders of magnitude shorter. **Cause, confirmed on device.** Eight `debuggerd -j` dumps taken about a second apart during a "before" upload caught the OkHttp thread in the same place 8 times out of 8, and never in a socket write: ``` at android.os.BinderProxy.transactNative(Native method) at android.content.ContentProviderProxy.query(ContentProviderNative.java:421) at android.content.ContentResolver.query(ContentResolver.java:944) at androidx.documentfile.provider.DocumentsContractApi19.queryForLong(DocumentsContractApi19.java:181) at androidx.documentfile.provider.SingleDocumentFile.length(SingleDocumentFile.java:83) at expo.modules.filesystem.unifiedfile.SAFDocumentFile.length(SAFDocumentFile.kt:90) at expo.modules.filesystem.FileSystemUploadTaskKt$asRequestBody$1.contentLength(FileSystemUploadTask.kt:261) at expo.modules.filesystem.CountingRequestBody.contentLength(FileSystemUploadTask.kt:231) at expo.modules.filesystem.CountingSink.write(FileSystemUploadTask.kt:254) at okio.RealBufferedSink.emitCompleteSegments(RealBufferedSink.kt:256) at okio.RealBufferedSink.writeAll(RealBufferedSink.kt:195) at okhttp3.internal.http.CallServerInterceptor.intercept(CallServerInterceptor.kt:62) ``` After the change the same upload finishes in under a second, and a SAF-backed upload costs about the same as a `file://` one — which is what it should cost, since `JavaFile.length()` was only ever a `stat`. This was first found in a production app (CoMapeo), where importing a custom offline map picked from the document picker ran at 1.8 MB/s and looked like a hang on low-end phones; the same import with this change lands in ~3 s. # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --------- Co-authored-by: Bartłomiej Klocek --- packages/expo-file-system/CHANGELOG.md | 1 + .../filesystem/FileSystemUploadTask.kt | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/expo-file-system/CHANGELOG.md b/packages/expo-file-system/CHANGELOG.md index 0c7a4f27309555..4e72af0f16fa68 100644 --- a/packages/expo-file-system/CHANGELOG.md +++ b/packages/expo-file-system/CHANGELOG.md @@ -23,6 +23,7 @@ - Fixed potential file offset races when asynchronous and synchronous `FileHandle` operations overlap on Android and iOS. ([#47945](https://github.com/expo/expo/pull/47945) by [@wh201906](https://github.com/wh201906)) - Fixed `readAsStringAsync` to respect `position` and `length` when reading UTF-8 strings. ([#20291](https://github.com/expo/expo/issues/20291) by [@mvincentong](https://github.com/mvincentong)) ([#45714](https://github.com/expo/expo/pull/45714) by [@mvincentong](https://github.com/mvincentong)) - [android] Fixed `rename()` storing an unencoded URI, so reading `.uri` afterwards threw for names containing a space. ([#48496](https://github.com/expo/expo/issues/48496) by [@yagiz2000](https://github.com/yagiz2000), [#48510](https://github.com/expo/expo/pull/48510) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway)) +- [android] Fixed slow uploads of SAF-backed `content://` files by resolving the request body's content length once, instead of querying it through `ContentResolver` for every 8 KiB written. ([#49206](https://github.com/expo/expo/pull/49206) by [@gmaclennan](https://github.com/gmaclennan)) ### 💡 Others diff --git a/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemUploadTask.kt b/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemUploadTask.kt index d58a1320c85b11..90e9a68228ad2f 100644 --- a/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemUploadTask.kt +++ b/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemUploadTask.kt @@ -226,12 +226,16 @@ private class CountingRequestBody( private val requestBody: RequestBody, private val progressListener: (Long, Long) -> Unit ) : RequestBody() { + // Resolved once: contentLength() is a ContentResolver query for SAF-backed + // files, and the counting sink needs the total on every segment written. + private val cachedContentLength: Long by lazy { requestBody.contentLength() } + override fun contentType() = requestBody.contentType() - override fun contentLength() = requestBody.contentLength() + override fun contentLength() = cachedContentLength override fun writeTo(sink: BufferedSink) { - val countingSink = CountingSink(sink, this, progressListener) + val countingSink = CountingSink(sink, cachedContentLength, progressListener) val bufferedSink = countingSink.buffer() requestBody.writeTo(bufferedSink) bufferedSink.flush() @@ -243,7 +247,7 @@ private class CountingRequestBody( */ private class CountingSink( sink: Sink, - private val requestBody: RequestBody, + private val totalBytes: Long, private val progressListener: (Long, Long) -> Unit ) : ForwardingSink(sink) { private var bytesWritten = 0L @@ -251,14 +255,19 @@ private class CountingSink( override fun write(source: Buffer, byteCount: Long) { super.write(source, byteCount) bytesWritten += byteCount - progressListener(bytesWritten, requestBody.contentLength()) + progressListener(bytesWritten, totalBytes) } } private fun UnifiedFileInterface.asRequestBody(contentType: String?): RequestBody { return object : RequestBody() { + // length() is a ContentResolver query for content:// files, and ContentProviderFile + // and AssetFile can fall back to reading the whole stream; resolve it once, and + // not before OkHttp asks for it. + private val resolvedLength: Long by lazy { length() } + override fun contentType() = contentType?.toMediaTypeOrNull() - override fun contentLength() = length() + override fun contentLength() = resolvedLength override fun writeTo(sink: BufferedSink) { inputStream().use { input -> val source = input.source() From 85bea284daefac87b216002b2dce10cb5aee1113 Mon Sep 17 00:00:00 2001 From: Hugo Extrat Date: Wed, 26 Aug 2026 13:16:52 +0200 Subject: [PATCH 08/17] [video][android] Don't require an Activity in VideoPlayer constructor (#48914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why Creating a `VideoPlayer` via `useVideoPlayer()` on Android throws when the current `Activity` is briefly unavailable — for example during a cold start with an immediate background/recreation of the Activity: ``` Call to function 'VideoPlayer.constructor' has been rejected. → Caused by: The current activity is no longer available ``` The constructor obtained the application context through `appContext.throwingActivity.applicationContext`, so it required an `Activity` it doesn't actually need — the application context does not depend on any Activity. This is the same failure family as #42939 (player creation while the app is backgrounded). # How Replaced `appContext.throwingActivity.applicationContext` with `appContext.reactContext?.applicationContext` in the `VideoPlayer` constructor (`VideoModule.kt`), following the pattern already used elsewhere in the repo (e.g. `VideoManager.onModuleCreated`). If the react context is gone, it now throws the typed `Exceptions.ReactContextLost()` instead of an NPE. Other `throwingActivity` usages were left untouched (`isPictureInPictureSupported`, fullscreen entry in `VideoView`) since those genuinely require an Activity. # Test Plan - `./gradlew :expo-video:compileDebugKotlin` — builds successfully. - `./gradlew :expo-video:spotlessCheck` — passes. - Manual: player creation no longer throws `MissingActivity` when the Activity is destroyed/recreating; playback works as before when the Activity is available. # Checklist - [x] Documentation is up to date to reflect these changes (eg: https://docs.expo.dev and README.md). - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) - [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). --------- Co-authored-by: Vojtech Novak --- packages/expo-video/CHANGELOG.md | 1 + .../android/src/main/java/expo/modules/video/VideoModule.kt | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/expo-video/CHANGELOG.md b/packages/expo-video/CHANGELOG.md index 7b303e80f99e21..c8e6acce797beb 100644 --- a/packages/expo-video/CHANGELOG.md +++ b/packages/expo-video/CHANGELOG.md @@ -12,6 +12,7 @@ ### 🐛 Bug fixes +- [Android] Fix `VideoPlayer` constructor throwing `MissingActivity` when the player is created while the `Activity` is briefly unavailable. ([#48914](https://github.com/expo/expo/pull/48914) by [@huextrat](https://github.com/huextrat)) - [iOS] Fixed a data race on the video cache's open-file registry, which could crash the app while the cache was being trimmed. ([#49286](https://github.com/expo/expo/pull/49286) by [@huextrat](https://github.com/huextrat)) - [iOS] Fixed a crash when the device runs out of storage while writing to the video cache. `FileHandle.writeData:` raises an uncatchable Objective-C `NSFileHandleOperationException` on `ENOSPC`; the throwing Swift APIs are now used so the error is caught and logged instead. ([#49284](https://github.com/expo/expo/pull/49284) by [@huextrat](https://github.com/huextrat)) - [Android] Guard `PictureInPictureParams.Builder.setAutoEnterEnabled` against `NoSuchMethodError` on stock OEM firmwares that report API 31+ without shipping the method, which crashed the app from `VideoView.onLayout` even when Picture in Picture was disabled. ([#48957](https://github.com/expo/expo/pull/48957) by [@onlyshyun](https://github.com/onlyshyun)) diff --git a/packages/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/packages/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt index 0cea60098b44e3..5499879c366253 100644 --- a/packages/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt +++ b/packages/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt @@ -6,6 +6,7 @@ import androidx.media3.common.Player.REPEAT_MODE_OFF import androidx.media3.common.Player.REPEAT_MODE_ONE import androidx.media3.common.util.UnstableApi import expo.modules.kotlin.Promise +import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.functions.Queues import expo.modules.kotlin.modules.Module @@ -73,7 +74,8 @@ class VideoModule : Module() { Class(VideoPlayer::class) { Constructor { source: VideoSource?, /* useSynchronousReplace - iOS-only */ _: Boolean?, playerBuilderOptions: PlayerBuilderOptions? -> - val player = VideoPlayer(appContext.throwingActivity.applicationContext, appContext, source, playerBuilderOptions) + val applicationContext = appContext.reactContext?.applicationContext ?: throw Exceptions.ReactContextLost() + val player = VideoPlayer(applicationContext, appContext, source, playerBuilderOptions) appContext.mainQueue.launch { player.prepare() } From a8ee863e182150ff64bda1f95c79936574a6873f Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:13:20 +0200 Subject: [PATCH 09/17] [router] Base useNavigationState on global state (#49392) # Why In order to remove `useSyncExternalStoreWithSelector` from `useNavigationState`. # How 1. Add `NavigatorStateContext` and use it to pass current navigator slice down in the tree 2. Remove `NavigationStateListenerProvider` and `useSyncExternalStoreWithSelector` usage # Test Plan CI # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --------- Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com> --- packages/expo-router/CHANGELOG.md | 1 + .../__tests__/useNavigationState.test.ios.tsx | 100 ++++++++++++++++++ .../core/useNavigationBuilder.tsx | 16 +-- .../core/useNavigationState.tsx | 66 ++---------- 4 files changed, 118 insertions(+), 65 deletions(-) diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index 32b5c3290f8829..5cc1d1df226c82 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -78,6 +78,7 @@ ### 💡 Others +- Base `useNavigationState` on global state. ([#49381](https://github.com/expo/expo/pull/49381) by [@jakub-agent](https://github.com/jakub-agent)) - Move Expo Router store values into React context. ([#49218](https://github.com/expo/expo/pull/49218) by [@Ubax](https://github.com/Ubax)) - Remove the ignored `linking.enabled` option. ([#49103](https://github.com/expo/expo/pull/49103) by [@Ubax](https://github.com/Ubax)) - Build the complete initial navigation state from a deep link. ([#49297](https://github.com/expo/expo/pull/49297) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useNavigationState.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useNavigationState.test.ios.tsx index 8e3bc4b6b3e748..2d1e35fe5ebf4d 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useNavigationState.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useNavigationState.test.ios.tsx @@ -125,6 +125,106 @@ test('gets the current navigation state with selector', () => { expect(callback.mock.calls[3]![0]).toBe(1); }); +test('updates a memoized consumer', () => { + const TestNavigator = (props: any): any => { + const { state, descriptors, NavigationContent } = useNavigationBuilder(MockRouter, props); + + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + + const callback = jest.fn(); + + const Test = React.memo(() => { + callback(useNavigationState((state) => state.index)); + + return null; + }); + + const navigation = React.createRef(); + + render( + + + + {() => null} + + + ); + + expect(callback).toHaveBeenLastCalledWith(0); + + act(() => navigation.current.navigate('second')); + + expect(callback).toHaveBeenLastCalledWith(1); +}); + +test('keeps filtered state stable when the container rerenders', () => { + const TestRouter = (options: any) => { + const router = MockRouter(options); + + return { + ...router, + getStateForAction(state: NavigationState, action: any) { + if (action.type === 'ROUTE_NAMES_CHANGED') { + return null; + } + + return router.getStateForAction(state, action, options); + }, + }; + }; + + const TestNavigator = (props: any): any => { + const { state, descriptors, NavigationContent } = useNavigationBuilder(TestRouter, props); + + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + + const callback = jest.fn(); + + const Test = React.memo(() => { + callback(useNavigationState((state) => state)); + + return null; + }); + + const initialState = { + stale: false as const, + routeKeySeq: 0, + key: 'root', + index: 0, + routeNames: ['first', 'hidden'], + routes: [ + { key: 'first-0', name: 'first' }, + { key: 'hidden-0', name: 'hidden' }, + ], + }; + + const App = (_props: { value: string }) => ( + + + + + + ); + + const root = render(); + + expect(callback).toHaveBeenCalledTimes(1); + + root.update(); + + expect(callback).toHaveBeenCalledTimes(1); +}); + test('gets the correct value if selector changes', () => { const TestNavigator = (props: any): any => { const { state, descriptors, NavigationContent } = useNavigationBuilder(MockRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx index 688118cb66df12..65535dc27be5a0 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx @@ -48,7 +48,7 @@ import { FocusedRouteKeyContext } from './useIsFocused'; import { useKeyedChildListeners } from './useKeyedChildListeners'; import { useLazyValue } from './useLazyValue'; import { useNavigationHelpers } from './useNavigationHelpers'; -import { NavigationStateListenerProvider } from './useNavigationState'; +import { NavigatorStateContext } from './useNavigationState'; import { emitBeforeRemove, getPreventableRoutes, @@ -327,6 +327,9 @@ export function useNavigationBuilder< ); } + // Screen-list changes invalidate render consumers even though the reducer reads committed config. + const routeNamesKey = routeNames.join('\0'); + const { state: currentState } = use(NavigationStateContext); const { getStateForKey, resetNavigator, handleAction } = use(NavigationBuilderContext); @@ -346,7 +349,10 @@ export function useNavigationBuilder< const committedState = ( isForeignType ? resetNavigatorState(currentState, router.type) : currentState ) as State; - const state = router.getStateForDeclaredRoutes(committedState, routeNames); + const state = React.useMemo( + () => router.getStateForDeclaredRoutes(committedState, routeNames), + [committedState, routeNamesKey, router] + ); // TODO(@ubax): Check whether this ref can be safely removed. const stateKeyRef = React.useRef(committedState.key); @@ -359,8 +365,6 @@ export function useNavigationBuilder< React.useInsertionEffect(() => { registryConfigRef.current = { routeNames, routeGetIdList }; }); - // Screen-list changes invalidate render consumers even though the reducer reads committed config. - const routeNamesKey = routeNames.join('\0'); const reduce = React.useCallback( (registryState, action) => // The registry stores states from different router types; this entry only receives its own state key. @@ -587,7 +591,7 @@ export function useNavigationBuilder< return ( - + @@ -595,7 +599,7 @@ export function useNavigationBuilder< - + ); diff --git a/packages/expo-router/src/react-navigation/core/useNavigationState.tsx b/packages/expo-router/src/react-navigation/core/useNavigationState.tsx index 795b2e6eedc8fc..f588edc4258e72 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationState.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationState.tsx @@ -1,8 +1,6 @@ 'use client'; import React, { use } from 'react'; -import useLatestCallback from '../../utils/useLatestCallback'; -import { useSyncExternalStoreWithSelector } from '../../utils/useSyncExternalStoreWithSelector'; import type { NavigationState, ParamListBase } from '../routers'; type Selector = (state: NavigationState) => T; @@ -15,67 +13,17 @@ type Selector = (state: NavigationState( selector: Selector ): T { - const stateListener = use(NavigationStateListenerContext); + const state = use(NavigatorStateContext); - if (stateListener == null) { + if (state == null) { throw new Error("Couldn't get the navigation state. Is your component inside a navigator?"); } - const value = useSyncExternalStoreWithSelector( - stateListener.subscribe, - // @ts-expect-error: this is unsafe, but needed to make the generic work - stateListener.getState, - stateListener.getState, - selector - ); - - return value; -} - -export function NavigationStateListenerProvider({ - state, - children, -}: { - state: NavigationState; - children: React.ReactNode; -}) { - const listeners = React.useRef<(() => void)[]>([]); - const stateRef = React.useRef(state); - - const getState = useLatestCallback(() => stateRef.current); - - const subscribe = useLatestCallback((callback: () => void) => { - listeners.current.push(callback); - - return () => { - listeners.current = listeners.current.filter((cb) => cb !== callback); - }; - }); - - React.useLayoutEffect(() => { - stateRef.current = state; - listeners.current.forEach((callback) => callback()); - }, [state]); - - const context = React.useMemo( - () => ({ - getState, - subscribe, - }), - [getState, subscribe] - ); - - return ( - - {children} - - ); + // TODO(@ubax): Restore selector equality bail-outs and stable result identity without a subscription. + // @ts-expect-error: this is unsafe, but needed to make the generic work + return selector(state); } -const NavigationStateListenerContext = React.createContext< - | { - getState: () => NavigationState; - subscribe: (callback: () => void) => () => void; - } - | undefined +export const NavigatorStateContext = React.createContext< + NavigationState | undefined >(undefined); From 1a6ed2a46d87c2fd757e4c11837104ca87b4953d Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:14:01 +0200 Subject: [PATCH 10/17] [router] Derive useIsFocused from context (#49390) # Why In order to remove `useSyncExternalStore` from `useIsFocused` # How Remove `useSyncExternalStore` fallback from `useIsFocused` # Test Plan CI # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --------- Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com> --- packages/expo-router/CHANGELOG.md | 1 + .../native-tabs/__tests__/render.test.ios.tsx | 10 +++--- .../core/__tests__/useIsFocused.test.ios.tsx | 29 +++++++++++++++- .../react-navigation/core/useIsFocused.tsx | 34 ++++--------------- 4 files changed, 40 insertions(+), 34 deletions(-) diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index 5cc1d1df226c82..abdb00174e49c6 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -78,6 +78,7 @@ ### 💡 Others +- Derive `useIsFocused` from context. ([#49390](https://github.com/expo/expo/pull/49390) by [@Ubax](https://github.com/Ubax)) - Base `useNavigationState` on global state. ([#49381](https://github.com/expo/expo/pull/49381) by [@jakub-agent](https://github.com/jakub-agent)) - Move Expo Router store values into React context. ([#49218](https://github.com/expo/expo/pull/49218) by [@Ubax](https://github.com/Ubax)) - Remove the ignored `linking.enabled` option. ([#49103](https://github.com/expo/expo/pull/49103) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx b/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx index 6c370886f53f7b..e949248bf9114a 100644 --- a/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx +++ b/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx @@ -292,11 +292,11 @@ describe('First focused tab', () => { expect(screen.getByTestId('first')).toBeVisible(); expect(screen.getByTestId('second')).toBeVisible(); - // TODO(@ubax): rework the ROUTE_NAMES_CHANGED and change this back to 4 - expect(TabsScreen).toHaveBeenCalledTimes(6); + // TODO(@ubax): when ROUTE_NAMES_CHANGED is reworked check if this can be reduced + expect(TabsScreen).toHaveBeenCalledTimes(4); expect(TabsScreen.mock.calls[0][0].screenKey).toBe('first'); expect(TabsScreen.mock.calls[1][0].screenKey).toBe('second'); - expect(TabsHost).toHaveBeenCalledTimes(3); + expect(TabsHost).toHaveBeenCalledTimes(2); expect(TabsHost.mock.calls[0][0].navStateRequest.selectedScreenKey).toBe('second'); }); @@ -322,10 +322,10 @@ describe('First focused tab', () => { expect(screen.getByTestId('first')).toBeVisible(); expect(screen.getByTestId('second')).toBeVisible(); - expect(TabsScreen).toHaveBeenCalledTimes(6); + expect(TabsScreen).toHaveBeenCalledTimes(4); expect(TabsScreen.mock.calls[0][0].screenKey).toBe('first'); expect(TabsScreen.mock.calls[1][0].screenKey).toBe('second'); - expect(TabsHost).toHaveBeenCalledTimes(3); + expect(TabsHost).toHaveBeenCalledTimes(2); expect(TabsHost.mock.calls[0][0].navStateRequest.selectedScreenKey).toBe('second'); }); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx index 4fc891c20991ae..8bd065e6a0fc8c 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import type { ParamListBase } from '../../routers'; import { Screen } from '../Screen'; import { createNavigationContainerRef } from '../createNavigationContainerRef'; -import { useIsFocused } from '../useIsFocused'; +import { IsFocusedContext, useIsFocused } from '../useIsFocused'; import { useNavigationBuilder } from '../useNavigationBuilder'; import { useRoute } from '../useRoute'; import { BaseNavigationContainer } from './__fixtures__/BaseNavigationContainer'; @@ -14,6 +14,33 @@ beforeEach(() => { MockRouterKey.current = 0; }); +test('uses the focus context without a navigation object', () => { + const Test = () => { + const isFocused = useIsFocused(); + + return <>{isFocused ? 'focused' : 'unfocused'}; + }; + + const root = render( + + + + ); + + expect(root).toMatchInlineSnapshot(`"focused"`); +}); + +test('throws without a focus context', () => { + const Test = () => { + useIsFocused(); + return null; + }; + + expect(() => render()).toThrow( + "Couldn't find a focus context. Make sure the component is rendered inside your app's route tree. This is most likely a bug in expo-router. Please report it at https://github.com/expo/expo/issues." + ); +}); + test('renders correct focus state', () => { const TestNavigator = (props: any): any => { const { state, descriptors, NavigationContent } = useNavigationBuilder(MockRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/useIsFocused.tsx b/packages/expo-router/src/react-navigation/core/useIsFocused.tsx index b3624bd82e318c..cb2984c1774721 100644 --- a/packages/expo-router/src/react-navigation/core/useIsFocused.tsx +++ b/packages/expo-router/src/react-navigation/core/useIsFocused.tsx @@ -2,8 +2,6 @@ import * as React from 'react'; import { use } from 'react'; -import { useNavigation } from './useNavigation'; - export const FocusedRouteKeyContext = React.createContext(undefined); export const IsFocusedContext = React.createContext(undefined); @@ -14,32 +12,12 @@ export const IsFocusedContext = React.createContext(undefin */ export function useIsFocused(): boolean { const isFocused = use(IsFocusedContext); - const navigation = useNavigation(); - - const isFocusedAvailable = isFocused !== undefined; - - const subscribe = React.useCallback( - (callback: () => void) => { - if (isFocusedAvailable) { - // If `isFocused` is available in context - // We don't need to subscribe to focus and blur events - return () => {}; - } - - const unsubscribeFocus = navigation.addListener('focus', callback); - const unsubscribeBlur = navigation.addListener('blur', callback); - - return () => { - unsubscribeFocus(); - unsubscribeBlur(); - }; - }, - [isFocusedAvailable, navigation] - ); - // isFocused from context only works with NavigationProvider - // So this is kept for backward compatibility - const value = React.useSyncExternalStore(subscribe, navigation.isFocused, navigation.isFocused); + if (isFocused === undefined) { + throw new Error( + "Couldn't find a focus context. Make sure the component is rendered inside your app's route tree. This is most likely a bug in expo-router. Please report it at https://github.com/expo/expo/issues." + ); + } - return isFocused ?? value; + return isFocused; } From a50125cfd64914a605113b8a0cdfb5c7aa80a651 Mon Sep 17 00:00:00 2001 From: Expo Bot <34669131+expo-bot@users.noreply.github.com> Date: Wed, 26 Aug 2026 05:32:27 -0700 Subject: [PATCH 11/17] Document how Expo Router picks a group for a shared route (#49370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!WARNING] > **Agent-authored and NOT human-reviewed.** An automated `/verify --fix` run for #49058 wrote this change and checked it in a sandbox; the reasoning and evidence are in the outcome comment on that issue. Review it as you would any external contribution. Requested by @brentvatne · [investigation run]((no run url)) · refs #49058 Two sibling route groups that hold the same child path share one URL, because a group segment is not part of the URL. In-app navigation keeps the group you are in. A cold link (page reload, new tab, bookmark, deep link) has no current group, so it renders the first alphabetical match. The shared routes page covered this in one sentence about page reloads, so users read the difference as a routing defect (#49058). This change rewrites that sentence in `docs/pages/router/advanced/shared-routes.mdx`: every shared route matches the same URL, in-app navigation keeps the current group, a cold link falls back to the first alphabetical match, and one URL can therefore render two different screens. It also warns against using shared routes for role-based screens. No runtime behaviour changes. I measured that behaviour with the router's own code at this commit, in the Web jest project. Vale, `oxfmt --check` and `pnpm test` in `docs/` all pass (620 tests, 58 suites).
Cause A group segment compiles to an optional regex group, so one URL matches every group that holds the path: [`getStateFromPath-forks.ts#L173-L178`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/fork/getStateFromPath-forks.ts#L173-L178). `getStateFromPath` takes the current segments as a third argument ([`getStateFromPath.ts#L70-L82`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/fork/getStateFromPath.ts#L70-L82)), and the config sorter prefers the candidate that shares more group segments with the current route ([`getStateFromPath-forks.ts#L329-L346`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/fork/getStateFromPath-forks.ts#L329-L346)). In-app navigation passes those segments ([`getNavigationAction.ts#L43-L49`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/global-state/getNavigationAction.ts#L43-L49)). A cold link passes none ([`useLinking.ts#L86`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/fork/useLinking.ts#L86), [`useStore.ts#L141`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/global-state/useStore.ts#L141)). The sorter then ties, the sort is stable, and file order decides. File order is `require.context` key order. Metro sorts the file list before it builds the context module, so the first group in alphabetical order wins: `files.slice().sort()` in `createFileMap`, `metro/src/lib/contextModuleTemplates.js` (line 36 in `metro@0.83.3`, which `@expo/metro@54.2.0` re-exports; the same line in `metro@0.84.5`, which this checkout resolves). That last step is a source read, not an end-to-end measurement. Protected routes do not take part in this. Guards are render-time only, so a URL that lands in a guarded group is redirected to the navigator anchor, not to the same path in the sibling group ([`useScreens.tsx#L364-L373`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/useScreens.tsx#L364-L373)).
Verification Route tree used for every arm: ``` app/index.tsx app/(creator)/_layout.tsx app/(creator)/dashboard.tsx app/(creator)/campaign-details/[id].tsx app/(guest)/_layout.tsx app/(guest)/browse.tsx app/(guest)/campaign-details/[id].tsx ``` I called the real `getStateFromPath(path, config, previousSegments)` with a config built by the router's own `getMockConfig`, and read the result back with `getRouteInfoFromState`. | Case | Current group | File order | URL | Resolved group | | --- | --- | --- | --- | --- | | A | none (cold link) | creator first | `/campaign-details/123` | `(creator)` | | D | none (cold link) | guest first | `/campaign-details/123` | `(guest)` | | B | `(guest)/browse` | creator first | `/campaign-details/123` | `(guest)` | | C | `(creator)/dashboard` | creator first | `/campaign-details/123` | `(creator)` | | G | `(guest)/browse` | guest first | `/campaign-details/123` | `(guest)` | | H | `(creator)/dashboard` | guest first | `/campaign-details/123` | `(creator)` | | E | none (cold link) | creator first | `/(guest)/campaign-details/123` | `(guest)` | A against D shows that a cold link follows file order. B, C, G and H show that in-app navigation keeps the current group, whatever the file order is. E shows that naming the group in the link pins the group. I then rendered the same tree with the router's own `renderRouter`, under the Web jest project in a `jsdom` environment. `initialUrl` is the cold link, and `router.push` is in-app navigation: ``` PASS Web src/__tests__/issue-49058.test.web.tsx ✓ cold link renders the first group in file order (147 ms) ✓ in-app navigation from (guest) keeps the guest group at the same url (83 ms) ✓ in-app navigation from (creator) keeps the creator group at the same url (44 ms) ✓ naming the group in the href pins the group on a cold link (27 ms) PASS Node src/__tests__/issue-49058.test.web.tsx ✓ cold link renders the first group in file order (100 ms) ✓ in-app navigation from (guest) keeps the guest group at the same url (70 ms) ✓ in-app navigation from (creator) keeps the creator group at the same url (42 ms) ✓ naming the group in the href pins the group on a cold link (21 ms) Tests: 8 passed, 8 total ``` The same four cases also pass in the iOS jest project, so this is not Web-specific. `getPathname()` stays `/campaign-details/123` in every case, while `getSegments()` changes group. These test files were measurement scaffolding and are not part of this change.
Checks run From `docs/`: - `.vale/bin/vale --config='.vale.ini' pages/router/advanced/shared-routes.mdx` — `0 errors, 0 warnings and 0 suggestions in 1 file`. - `oxfmt --check pages/router/advanced/shared-routes.mdx` — `All matched files use the correct format.` - `pnpm test` — `Test Suites: 58 passed, 58 total`, `Tests: 620 passed, 620 total`, `Snapshots: 32 passed, 32 total`. - `pnpm test:worker` — `All tests passed!`. - `tsc --noEmit -p .` — clean. `pnpm lint` also runs `oxlint`, which crashed in my sandbox with a Rust allocator panic (`oxc_allocator/src/pool/fixed_size.rs:112`) under memory pressure. `oxlint` lints only JS and TS files; this change touches neither. I did not run `expo-router` package checks, because this change does not touch that package.
Not covered - This change does not alter routing behaviour, so no app behaviour is affected. - The array syntax `(home,search)` builds the same two routes in memory and should behave the same way, but I did not measure it. - I did not drive a real browser. I ran the router in the Web jest project with `jsdom` instead. - I did not measure `require.context` key order end to end; the alphabetical step is a source read of Metro. - No test in the repository covers a cold link to an ambiguous shared path. Every current "stay in the group" test navigates into a group first. That gap remains.
Options considered 1. **Warn in development when two groups expose the same URL.** Shared routes are a supported feature, and the array syntax `(home,search)` creates this shape on purpose, so the warning would fire on apps that are correct today. Rejected: it would add noise to every shared-routes app and to the repository's own `apps/router-e2e` projects. 2. **Make URL resolution guard-aware, so a cold link prefers an unguarded sibling group.** Guards live only in React render context (`layouts/GuardContext.tsx`), while resolution runs before render in `fork/getStateFromPath.ts`. Rejected: it needs a new data path from render state into the linking layer, it changes which screen existing cold links open, and that is a design decision for a maintainer. 3. **Sort sibling groups explicitly instead of relying on `require.context` order.** Metro already sorts the file list before it builds the context module, so no app would resolve a URL differently. Rejected: it only removes a dependency on Metro's ordering, and it does not address the reported confusion. 4. **Do nothing and document the behaviour.** Chosen: the behaviour is intentional and already partly documented; the gap is that the page never states that in-app navigation and a cold link resolve differently, which is exactly what the reporter hit.
--------- Co-authored-by: expo-bot Co-authored-by: Aman Mittal --- docs/pages/router/advanced/shared-routes.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/pages/router/advanced/shared-routes.mdx b/docs/pages/router/advanced/shared-routes.mdx index e74fc2d59aeac0..6fdae531a4633d 100644 --- a/docs/pages/router/advanced/shared-routes.mdx +++ b/docs/pages/router/advanced/shared-routes.mdx @@ -21,9 +21,11 @@ In the example below, **src/app/\_layout.tsx** is the tab bar and each route has ]} /> -> When reloading the page, the first alphabetical match is rendered. +Group segments are not part of the URL, so every shared route matches the same URL. Expo Router uses the group you are in to select between them. In-app navigation keeps the current group. A page reload, a bookmark, a shared URL, and a deep link are all cold links. A cold link has no current group, so Expo Router renders the first alphabetical match. The same URL can therefore render one screen after in-app navigation and a different screen after a page reload. -Shared routes can be navigated directly by including the group name in the route. For example, `/(search)/baconbrix` navigates to `/baconbrix` in the "search" layout. +Shared routes can be navigated directly by including the group name in the route. For example, `/(search)/baconbrix` navigates to `/baconbrix` in the "search" layout. Use this form when a link must always open one specific group. + +> **warning** Do not use shared routes to give different user roles a different version of a screen. The URL does not carry the user's role, so a cold link cannot select the correct group, and [protected routes](/router/advanced/protected/) do not change that. Declare the screen once and control access with `Stack.Protected`. ## Arrays From a2cf5436bbe65bd48fb64157ece13eb2900383d9 Mon Sep 17 00:00:00 2001 From: stvrmrz <159386283+stvrmrz@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:59:33 -0600 Subject: [PATCH 12/17] [expo-audio] Preserve the record(forDuration:) limit across an audio session interruption (#49239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why On iOS, a recording armed with `record({ forDuration })` loses its duration limit if an audio session interruption occurs. `handleInterruptionBegan` pauses the recorder, and the `.shouldResume` path reaches `startRecording()`, which resumes with a **bare `ref.record()`** — carrying no duration. The recording then continues indefinitely. An incoming call, an alarm, or Siri is enough to trigger it, and nothing in the app is called, so there is no opportunity to re-arm from JS. Measured on a physical iPhone 17 Pro Max (iOS 26.5), `expo-audio@57.0.3`: a recorder armed `forDuration: 6.0`, paused, then resumed via the bare call captured **14.06 s and was still going**. # What this changes `AudioRecorder` remembers the duration a recording was armed with, and `startRecording()` re-applies it instead of resuming unbounded. Two details that matter: **The limit is re-applied absolutely, not as a remainder.** `record(forDuration:)` stops when the recorder's own `currentTime` *reaches* the value, and `currentTime` is cumulative across pause/resume — so the original limit is correct. Re-arming with a computed remainder was measured to end the recording early: 3.98 s against a 6.0 s arm. **`resetDurationTracking()` deliberately does not clear the limit.** `updateStateForDirectRecording()` calls that helper *after* `record(forDuration:)` has armed the limit, so clearing it there would discard the value the caller just set — and the first interruption would resume unbounded again. The limit is cleared at capture boundaries instead: `prepare`, `stopRecording`, `didFinish`, `encodeErrorDidOccur`, and `handleMediaServicesReset`. If the allowance is already spent when the resume arrives, the recorder stops rather than resuming, which routes through the existing `didFinish` path and emits `isFinished` as usual. All three arming paths (`record({ forDuration })`, `record({ atTime, forDuration })`, and the deprecated `recordForDuration`) now go through one method, so none of them can bypass the limit. # Test Plan Verified on a physical device against `expo-audio@57.0.3` with the equivalent change applied: - armed 6.0 s, paused, resumed bare → **before:** 14.06 s and climbing; **after:** stops at 6.000 s - a real Clock-alarm interruption during a 120 s-capped recording → resumes and ends at **exactly 120.0000 s** of audio, verified by parsing the CAF `data` chunk, across 4 trials - one canonical file throughout; the recorder's `didFinish` fires normally at the limit I could not run the repo's own iOS test suite for `expo-audio`; the verification above is device-level against the published package. --------- Co-authored-by: Wojciech Dróżdż --- packages/expo-audio/CHANGELOG.md | 2 + .../java/expo/modules/audio/AudioRecorder.kt | 42 +++++++--- packages/expo-audio/ios/AudioModule.swift | 13 ++- packages/expo-audio/ios/AudioRecorder.swift | 44 ++++++++++- packages/expo-audio/src/Audio.types.ts | 5 +- packages/expo-audio/src/AudioRecorder.web.ts | 57 ++++++++++--- .../src/__tests__/AudioRecorderWeb-test.ts | 79 +++++++++++++++++++ 7 files changed, 208 insertions(+), 34 deletions(-) diff --git a/packages/expo-audio/CHANGELOG.md b/packages/expo-audio/CHANGELOG.md index b20afa3b2267a9..65ac4d05d611d9 100644 --- a/packages/expo-audio/CHANGELOG.md +++ b/packages/expo-audio/CHANGELOG.md @@ -4,6 +4,7 @@ ### 🛠 Breaking changes +- Paused time is now excluded from the `AudioRecorder` duration limit. ([#49239](https://github.com/expo/expo/pull/49239) by [@stvrmrz](https://github.com/stvrmrz) and [@behenate](https://github.com/behenate)) - [Android] Aligned the default audio focus request on Android 7.0–7.1 with newer versions by using transient exclusive focus when no interruption mode has been configured. ([#49101](https://github.com/expo/expo/pull/49101) by [@behenate](https://github.com/behenate)) ### 🎉 New features @@ -18,6 +19,7 @@ ### 🐛 Bug fixes +- Preserve recorder duration limits across pauses, consistently exclude paused time on Android, iOS, and Web. ([#49239](https://github.com/expo/expo/pull/49239) by [@stvrmrz](https://github.com/stvrmrz) and [@behenate](https://github.com/behenate)) - [Android] Pause audio players and playlists when headphones or Bluetooth audio devices disconnect. ([#48151](https://github.com/expo/expo/pull/48151) by [@vivekjm](https://github.com/vivekjm)) - [Android] Give the lock-screen `MediaSession` instances a unique ID so concurrent active players (and the basic session) no longer collide on the empty default. ([#47101](https://github.com/expo/expo/issues/47101) by [@tsushanth](https://github.com/tsushanth)) - [Android] Fix stale lock screen artwork when updating metadata without an `artworkUrl`. ([#45738](https://github.com/expo/expo/pull/45738) by [@behenate](https://github.com/behenate)) diff --git a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecorder.kt b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecorder.kt index 8f80ba519ce28a..3150dcb8ac4a8a 100644 --- a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecorder.kt +++ b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecorder.kt @@ -47,6 +47,7 @@ class AudioRecorder( var isRecording = false var isPaused = false private var recordingTimerJob: Job? = null + private var durationLimitMillis: Long? = null var useForegroundService = false val serviceConnection = AudioRecordingServiceConnection(WeakReference(this), appContext) @@ -118,6 +119,11 @@ class AudioRecorder( } } + if (isPaused && durationLimitMillis?.let { durationAlreadyRecorded >= it } == true) { + stopRecording() + return + } + if (isPaused) { recorder?.resume() } else { @@ -126,26 +132,23 @@ class AudioRecorder( startTime = System.currentTimeMillis() isRecording = true isPaused = false + scheduleRecordingStop() } fun recordWithOptions(atTimeSeconds: Double? = null, forDurationSeconds: Double? = null) { recordingTimerJob?.cancel() + recordingTimerJob = null // Note: atTime is not supported on Android (no native equivalent), so we ignore it entirely // Only forDuration is implemented using coroutines + if (forDurationSeconds != null) { + durationLimitMillis = (forDurationSeconds * 1000).toLong() + } else if (!isPaused || atTimeSeconds != null) { + // A bare record() resumes the current capture and keeps its limit. + durationLimitMillis = null + } - forDurationSeconds?.let { - record() - recordingTimerJob = appContext?.mainQueue?.launch { - delay((it * 1000).toLong()) - // Stop recording regardless of current state - // This matches the iOS behaviour where the timer continues regardless of if - // the recording was paused. - if (isRecording || isPaused) { - stopRecording() - } - } - } ?: record() + record() } // Keep backward compatibility methods @@ -158,12 +161,26 @@ class AudioRecorder( } fun pauseRecording() { + recordingTimerJob?.cancel() + recordingTimerJob = null recorder?.pause() durationAlreadyRecorded = getAudioRecorderDurationMillis() isRecording = false isPaused = true } + private fun scheduleRecordingStop() { + recordingTimerJob?.cancel() + recordingTimerJob = durationLimitMillis?.let { limit -> + appContext?.mainQueue?.launch { + delay((limit - getAudioRecorderDurationMillis()).coerceAtLeast(0)) + if (isRecording) { + stopRecording() + } + } + } + } + fun stopRecording(): Bundle { val url = currentFileUrl() var durationMillis: Long @@ -220,6 +237,7 @@ class AudioRecorder( isRecording = false isPaused = false durationAlreadyRecorded = 0 + durationLimitMillis = null startTime = 0L isPrepared = false } diff --git a/packages/expo-audio/ios/AudioModule.swift b/packages/expo-audio/ios/AudioModule.swift index 65ea4095527262..e535c1b42ddb4e 100644 --- a/packages/expo-audio/ios/AudioModule.swift +++ b/packages/expo-audio/ios/AudioModule.swift @@ -503,18 +503,15 @@ public class AudioModule: Module { case let (atTime?, forDuration?): // Convert relative delay to absolute device time let absoluteTime = recorder.ref.deviceCurrentTime + TimeInterval(atTime) - recorder.ref.record(atTime: absoluteTime, forDuration: TimeInterval(forDuration)) - recorder.updateStateForDirectRecording() + recorder.recordForDuration(TimeInterval(forDuration), atTime: absoluteTime) return recorder.getRecordingStatus() case let (atTime?, nil): // Convert relative delay to absolute device time let absoluteTime = recorder.ref.deviceCurrentTime + TimeInterval(atTime) - recorder.ref.record(atTime: absoluteTime) - recorder.updateStateForDirectRecording() + recorder.record(atTime: absoluteTime) return recorder.getRecordingStatus() case let (nil, forDuration?): - recorder.ref.record(forDuration: TimeInterval(forDuration)) - recorder.updateStateForDirectRecording() + recorder.recordForDuration(TimeInterval(forDuration)) return recorder.getRecordingStatus() case (nil, nil): return try recorder.startRecording() @@ -537,12 +534,12 @@ public class AudioModule: Module { Function("startRecordingAtTime") { (recorder, seconds: Double) in try checkPermissions() - recorder.ref.record(atTime: TimeInterval(seconds)) + recorder.record(atTime: TimeInterval(seconds)) } Function("recordForDuration") { (recorder, seconds: Double) in try checkPermissions() - recorder.ref.record(forDuration: TimeInterval(seconds)) + recorder.recordForDuration(TimeInterval(seconds)) } Function("getAvailableInputs") { diff --git a/packages/expo-audio/ios/AudioRecorder.swift b/packages/expo-audio/ios/AudioRecorder.swift index a6d148f9531b77..73c6a036be6c76 100644 --- a/packages/expo-audio/ios/AudioRecorder.swift +++ b/packages/expo-audio/ios/AudioRecorder.swift @@ -23,6 +23,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { private var mediaServicesDidReset = false private var currentOptions: RecordingOptions? private var currentSessionOptions: AVAudioSession.CategoryOptions = [] + private var durationLimitSeconds: TimeInterval? private var isPrepared: Bool { currentState == .prepared || currentState == .recording || currentState == .paused @@ -85,6 +86,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { ref.stop() } resetDurationTracking() + durationLimitSeconds = nil mediaServicesDidReset = false let session = AVAudioSession.sharedInstance() @@ -127,6 +129,32 @@ class AudioRecorder: SharedRef, RecordingResultHandler { private func resetDurationTracking() { startTimestamp = 0 totalRecordedDuration = 0 + // `durationLimitSeconds` is deliberately NOT cleared here. This helper runs + // from `updateStateForDirectRecording()`, which is called *after* a + // `record(forDuration:)` has already armed the limit, so clearing it here + // would discard the limit the caller just set. It is cleared at capture + // boundaries instead. + } + + func recordForDuration(_ seconds: TimeInterval, atTime absoluteTime: TimeInterval? = nil) { + if currentState == .paused && ref.currentTime >= seconds { + stopRecording() + return + } + + if let absoluteTime { + ref.record(atTime: absoluteTime, forDuration: seconds) + } else { + ref.record(forDuration: seconds) + } + updateStateForDirectRecording() + durationLimitSeconds = seconds + } + + func record(atTime absoluteTime: TimeInterval) { + ref.record(atTime: absoluteTime) + updateStateForDirectRecording() + durationLimitSeconds = nil } func startRecording() throws -> [String: Any] { @@ -142,7 +170,17 @@ class AudioRecorder: SharedRef, RecordingResultHandler { resetDurationTracking() } - ref.record() + // If the recording was armed with a duration, re-apply it. + if let limit = durationLimitSeconds { + if ref.currentTime >= limit { + // The allowance is already spent; stop rather than resume. + stopRecording() + return getRecordingStatus() + } + ref.record(forDuration: limit) + } else { + ref.record() + } startTimestamp = deviceCurrentTime currentState = .recording @@ -166,6 +204,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { ref.stop() currentState = .stopped resetDurationTracking() + durationLimitSeconds = nil } func pauseRecording() { @@ -200,6 +239,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { func handleMediaServicesReset() { mediaServicesDidReset = true resetDurationTracking() + durationLimitSeconds = nil if let options = currentOptions { do { @@ -231,6 +271,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { // Update internal state when recording finishes automatically (e.g., from recordForDuration) currentState = .stopped resetDurationTracking() + durationLimitSeconds = nil emit(event: recordingStatus, payload: [ "id": id, @@ -245,6 +286,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { // Update internal state on error currentState = .error resetDurationTracking() + durationLimitSeconds = nil emit(event: recordingStatus, payload: [ "id": id, diff --git a/packages/expo-audio/src/Audio.types.ts b/packages/expo-audio/src/Audio.types.ts index 89e76b444d775f..fe5f5c3a08d2be 100644 --- a/packages/expo-audio/src/Audio.types.ts +++ b/packages/expo-audio/src/Audio.types.ts @@ -370,8 +370,9 @@ export type BitRateStrategy = 'constant' | 'longTermAverage' | 'variableConstrai */ export type RecordingStartOptions = { /** - * The duration in seconds after which recording should automatically stop. - * If not provided, recording continues until manually stopped. + * The maximum duration of recorded audio, in seconds. Time spent paused does not count + * toward the limit. A limited recording keeps the same limit when resumed without options. + * If not provided when starting a new recording, recording continues until manually stopped. * * @platform ios * @platform android diff --git a/packages/expo-audio/src/AudioRecorder.web.ts b/packages/expo-audio/src/AudioRecorder.web.ts index c85d5d6cf2a962..707dd38063af47 100644 --- a/packages/expo-audio/src/AudioRecorder.web.ts +++ b/packages/expo-audio/src/AudioRecorder.web.ts @@ -20,6 +20,8 @@ export class AudioRecorderWeb } async setup() { + this.clearTimeouts(); + this.durationLimitMillis = null; this.mediaRecorder = await this.createMediaRecorder(this.options); } @@ -32,6 +34,7 @@ export class AudioRecorderWeb private mediaRecorderUptimeOfLastStartResume = 0; private mediaRecorderIsRecording = false; private timeoutIds: ReturnType[] = []; + private durationLimitMillis: number | null = null; private cachedInputs: RecordingInput[] = []; private selectedDeviceId: string | null = null; private stream: MediaStream | null = null; @@ -53,22 +56,31 @@ export class AudioRecorderWeb ); } - // Clear any existing timeouts + const wasPaused = this.mediaRecorder.state === 'paused'; this.clearTimeouts(); // Note: atTime is not supported on Web (no native equivalent), so we ignore it entirely // Only forDuration is implemented using setTimeout - const { forDuration } = options || {}; - - this.startActualRecording(); - + const { atTime, forDuration } = options || {}; if (forDuration !== undefined) { - this.timeoutIds.push( - setTimeout(() => { - this.stop(); - }, forDuration * 1000) - ); + this.durationLimitMillis = forDuration * 1000; + } else if (!wasPaused || atTime !== undefined) { + // A bare record() resumes the current capture and keeps its limit. Starting a + // new capture without forDuration, including record({ atTime }), clears it. + this.durationLimitMillis = null; } + + if ( + wasPaused && + this.durationLimitMillis !== null && + this.getAudioRecorderDurationMillis() >= this.durationLimitMillis + ) { + this.stop(); + return; + } + + this.startActualRecording(); + this.scheduleRecordingStop(); } private startActualRecording(): void { @@ -126,7 +138,8 @@ export class AudioRecorderWeb ); } - this.mediaRecorder?.pause(); + this.clearTimeouts(); + this.mediaRecorder.pause(); } recordForDuration(seconds: number): void { @@ -151,6 +164,9 @@ export class AudioRecorderWeb ); } + this.clearTimeouts(); + this.durationLimitMillis = null; + const dataPromise = new Promise((resolve) => this.mediaRecorder?.addEventListener('dataavailable', (e) => resolve(e.data)) ); @@ -174,6 +190,23 @@ export class AudioRecorderWeb clearTimeouts() { this.timeoutIds.forEach((id) => clearTimeout(id)); + this.timeoutIds = []; + } + + private scheduleRecordingStop() { + if (this.durationLimitMillis === null) { + return; + } + + const remainingDuration = Math.max( + 0, + this.durationLimitMillis - this.getAudioRecorderDurationMillis() + ); + this.timeoutIds.push( + setTimeout(() => { + this.stop(); + }, remainingDuration) + ); } private async createMediaRecorder( @@ -252,6 +285,8 @@ export class AudioRecorderWeb }); mediaRecorder?.addEventListener('stop', () => { + this.clearTimeouts(); + this.durationLimitMillis = null; this.currentTime = 0; this.mediaRecorderIsRecording = false; this.stream = null; diff --git a/packages/expo-audio/src/__tests__/AudioRecorderWeb-test.ts b/packages/expo-audio/src/__tests__/AudioRecorderWeb-test.ts index b42a62e023fed3..c7b6cf9c425cd8 100644 --- a/packages/expo-audio/src/__tests__/AudioRecorderWeb-test.ts +++ b/packages/expo-audio/src/__tests__/AudioRecorderWeb-test.ts @@ -87,3 +87,82 @@ describe('AudioRecorderWeb fileSize', () => { expect(recorder.getStatus().fileSize).toBe(0); }); }); + +describe('AudioRecorderWeb duration limit', () => { + beforeEach(() => { + mockMediaDevices(); + jest.useFakeTimers(); + jest.setSystemTime(1000); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('counts recorded time and preserves the limit across a bare resume', async () => { + const recorder = new AudioRecorderWeb({}); + await recorder.prepareToRecordAsync(); + const stopSpy = jest.spyOn(recorder, 'stop').mockResolvedValue(undefined); + + recorder.record({ forDuration: 10 }); + jest.advanceTimersByTime(3000); + recorder.pause(); + + jest.advanceTimersByTime(20000); + expect(recorder.getStatus().durationMillis).toBe(3000); + expect(stopSpy).not.toHaveBeenCalled(); + + recorder.record(); + jest.advanceTimersByTime(6999); + expect(stopSpy).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(stopSpy).toHaveBeenCalledTimes(1); + }); + + it('treats a new forDuration value as an absolute recording limit', async () => { + const recorder = new AudioRecorderWeb({}); + await recorder.prepareToRecordAsync(); + const stopSpy = jest.spyOn(recorder, 'stop').mockResolvedValue(undefined); + + recorder.record({ forDuration: 10 }); + jest.advanceTimersByTime(3000); + recorder.pause(); + recorder.record({ forDuration: 5 }); + + jest.advanceTimersByTime(1999); + expect(stopSpy).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(stopSpy).toHaveBeenCalledTimes(1); + }); + + it('clears a previous limit when arming without a duration', async () => { + const recorder = new AudioRecorderWeb({}); + await recorder.prepareToRecordAsync(); + const stopSpy = jest.spyOn(recorder, 'stop').mockResolvedValue(undefined); + + recorder.record({ forDuration: 10 }); + jest.advanceTimersByTime(3000); + recorder.pause(); + recorder.record({ atTime: 1 }); + jest.advanceTimersByTime(20000); + + expect(stopSpy).not.toHaveBeenCalled(); + }); + + it('stops instead of resuming when a replacement limit is already spent', async () => { + const recorder = new AudioRecorderWeb({}); + await recorder.prepareToRecordAsync(); + const stopSpy = jest.spyOn(recorder, 'stop').mockResolvedValue(undefined); + + recorder.record({ forDuration: 10 }); + jest.advanceTimersByTime(3000); + recorder.pause(); + recorder.record({ forDuration: 2 }); + + expect(stopSpy).toHaveBeenCalledTimes(1); + expect(recorder.isRecording).toBe(false); + }); +}); From d0f2decfd71ec6861e5f8ee7bd6e039888ee6fa6 Mon Sep 17 00:00:00 2001 From: Aman Mittal Date: Wed, 26 Aug 2026 19:06:14 +0530 Subject: [PATCH 13/17] [docs] Update EAS dashboard entries (#49395) # Why Add missing EAS dashboard entries (for example, Observe, Runtimes, etc.) in the Search UI box and fix the broken one. # Test Plan CleanShot 2026-08-26 at 17 10
51@2x CleanShot 2026-08-26 at 17 10
59@2x # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- docs/ui/components/Search/expoEntries.ts | 38 +++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/ui/components/Search/expoEntries.ts b/docs/ui/components/Search/expoEntries.ts index 2b19283727ec5b..44d4e3c3dd6732 100644 --- a/docs/ui/components/Search/expoEntries.ts +++ b/docs/ui/components/Search/expoEntries.ts @@ -3,16 +3,22 @@ import { BranchIcon } from '@expo/styleguide-icons/custom/BranchIcon'; import { BuildIcon } from '@expo/styleguide-icons/custom/BuildIcon'; import { CredentialIcon } from '@expo/styleguide-icons/custom/CredentialIcon'; import { EasSubmitIcon } from '@expo/styleguide-icons/custom/EasSubmitIcon'; +import { GithubIcon } from '@expo/styleguide-icons/custom/GithubIcon'; import { Smartphone01Icon } from '@expo/styleguide-icons/custom/Smartphone01Icon'; import { Cloud01DuotoneIcon } from '@expo/styleguide-icons/duotone/Cloud01DuotoneIcon'; import { Fingerprint03DuotoneIcon } from '@expo/styleguide-icons/duotone/Fingerprint03DuotoneIcon'; +import { ActivityHeartIcon } from '@expo/styleguide-icons/outline/ActivityHeartIcon'; +import { BarChart01Icon } from '@expo/styleguide-icons/outline/BarChart01Icon'; import { BracketsXIcon } from '@expo/styleguide-icons/outline/BracketsXIcon'; +import { CpuChip01Icon } from '@expo/styleguide-icons/outline/CpuChip01Icon'; import { Cube02Icon } from '@expo/styleguide-icons/outline/Cube02Icon'; import { DataIcon } from '@expo/styleguide-icons/outline/DataIcon'; +import { Database01Icon } from '@expo/styleguide-icons/outline/Database01Icon'; import { Dataflow03Icon } from '@expo/styleguide-icons/outline/Dataflow03Icon'; import { FileSearch02Icon } from '@expo/styleguide-icons/outline/FileSearch02Icon'; import { Grid01Icon } from '@expo/styleguide-icons/outline/Grid01Icon'; import { LayersTwo02Icon } from '@expo/styleguide-icons/outline/LayersTwo02Icon'; +import { Monitor01Icon } from '@expo/styleguide-icons/outline/Monitor01Icon'; import { NotificationBoxIcon } from '@expo/styleguide-icons/outline/NotificationBoxIcon'; import { Settings01Icon } from '@expo/styleguide-icons/outline/Settings01Icon'; import type { ComponentType, HTMLAttributes } from 'react'; @@ -59,6 +65,16 @@ export const entries: ExpoItemType[] = [ url: 'https://expo.dev/accounts/[account]/projects/[project]/insights', Icon: DataIcon, }, + { + label: 'Project usage', + url: 'https://expo.dev/accounts/[account]/projects/[project]/usage', + Icon: BarChart01Icon, + }, + { + label: 'Project observe', + url: 'https://expo.dev/accounts/[account]/projects/[project]/observe', + Icon: ActivityHeartIcon, + }, { label: 'Project workflows', url: 'https://expo.dev/accounts/[account]/projects/[project]/workflows', @@ -69,6 +85,11 @@ export const entries: ExpoItemType[] = [ url: 'https://expo.dev/accounts/[account]/projects/[project]/development-builds', Icon: Smartphone01Icon, }, + { + label: 'Project simulator sessions', + url: 'https://expo.dev/accounts/[account]/projects/[project]/simulator-sessions', + Icon: Monitor01Icon, + }, { label: 'Project builds', url: 'https://expo.dev/accounts/[account]/projects/[project]/builds', @@ -94,6 +115,11 @@ export const entries: ExpoItemType[] = [ url: 'https://expo.dev/accounts/[account]/projects/[project]/updates', Icon: LayersTwo02Icon, }, + { + label: 'Project update runtimes', + url: 'https://expo.dev/accounts/[account]/projects/[project]/runtimes', + Icon: CpuChip01Icon, + }, { label: 'Project hosting', url: 'https://expo.dev/accounts/[account]/projects/[project]/hosting', @@ -104,9 +130,14 @@ export const entries: ExpoItemType[] = [ url: 'https://expo.dev/accounts/[account]/projects/[project]/push-notifications', Icon: NotificationBoxIcon, }, + { + label: 'Project caches', + url: 'https://expo.dev/accounts/[account]/projects/[project]/caches', + Icon: Database01Icon, + }, { label: 'Project fingerprints', - url: 'https://expo.dev/accounts/[account]/projects/[project]/distribution', + url: 'https://expo.dev/accounts/[account]/projects/[project]/fingerprints', Icon: Fingerprint03DuotoneIcon, }, { @@ -124,4 +155,9 @@ export const entries: ExpoItemType[] = [ url: 'https://expo.dev/accounts/[account]/projects/[project]/environment-variables', Icon: BracketsXIcon, }, + { + label: 'Project GitHub', + url: 'https://expo.dev/accounts/[account]/projects/[project]/github', + Icon: GithubIcon, + }, ]; From d33a420e1b9046486240fc46d2392e5797318917 Mon Sep 17 00:00:00 2001 From: Aman Mittal Date: Wed, 26 Aug 2026 19:06:24 +0530 Subject: [PATCH 14/17] [docs] Defer the search bundle on every page (#49393) # Why `Search.tsx` already lazy-loads the command menu. `CommandMenu` sits behind `React.lazy`, and the trigger comes from the package's separate lightweight `/trigger` entry. The intent was right and the code looked right but it wasn't working as expected. `ExpoDashboardItem.tsx` imports `addHighlight` and `CommandItemBaseWithCopy` from the main `@expo/styleguide-search-ui` entry, and `Search.tsx` imported that component at the top level. One static import is enough for webpack to pull the whole package into the sync graph, so it all landed in `_app` anyway and the `lazy()` ended up resolving a module that was already loaded. The `_app` chunk goes from 617,870 to 406,383 bytes (gzip -9), so about 207 KB off every page, and 16 packages leave the chunk entirely. Measured on `/versions/latest/sdk/calendar/` with local Lighthouse at median of 5: page weight 1363 KB to 1138 KB, Total Blocking Time 818 ms to 715 ms. The TBT ranges don't overlap (799-832 before, 700-729 after). # Test Plan N/A # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- docs/ui/components/Search/Search.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui/components/Search/Search.tsx b/docs/ui/components/Search/Search.tsx index d58ded3441d997..0bd57cdf18731b 100644 --- a/docs/ui/components/Search/Search.tsx +++ b/docs/ui/components/Search/Search.tsx @@ -5,7 +5,6 @@ import { lazy, ReactNode, Suspense, useEffect, useRef, useState } from 'react'; import { usePageApiVersion } from '~/providers/page-api-version'; import versions from '~/public/static/constants/versions.json'; -import { ExpoDashboardItem } from './ExpoDashboardItem'; import { entries } from './expoEntries'; const CommandMenu = lazy(() => @@ -57,6 +56,7 @@ export const Search = ({ mainSection }: SearchProps) => { const filteredEntries = entries.filter(entry => entry.label.toLowerCase().includes(query.toLowerCase()) ); + const { ExpoDashboardItem } = await import('./ExpoDashboardItem'); setExpoDashboardItems( filteredEntries.map(item => ) ); From 0e140a51112afd01a8a708d33a2162c527a85924 Mon Sep 17 00:00:00 2001 From: Alan Hughes <30924086+alanjhughes@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:24:04 +0100 Subject: [PATCH 15/17] [ios][audio] Guard the remaining crash paths when mic permission is missing (#49162) --- apps/bare-expo/ios/Podfile.lock | 2 +- packages/expo-audio/CHANGELOG.md | 1 + packages/expo-audio/ios/AudioExceptions.swift | 6 +++++ packages/expo-audio/ios/AudioModule.swift | 3 +++ .../ios/AudioRecordingRequester.swift | 21 ++++++++++++++- .../Tests/AudioRecordingRequesterTests.swift | 26 +++++++++++++++++++ 6 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/bare-expo/ios/Podfile.lock b/apps/bare-expo/ios/Podfile.lock index 9b072f0823d5d2..12633901277b30 100644 --- a/apps/bare-expo/ios/Podfile.lock +++ b/apps/bare-expo/ios/Podfile.lock @@ -4113,7 +4113,7 @@ SPEC CHECKSUMS: EXUpdates: e8c071fa483afb3d9d1de09e8f4f9427278af0a1 EXUpdatesInterface: 92d2aa5194b6fb93ca8ab749db75665cb28b2e60 FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d - hermes-engine: dd9a9191125ffe9f57c224173db85afb0210cd23 + hermes-engine: 4c998771d5218e20701b63d8358199a520a54447 JestMockSchema: 96b4cfec246644f6323d1c30693b6a02044be1e6 libavif: 5f8e715bea24debec477006f21ef9e95432e254d libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f diff --git a/packages/expo-audio/CHANGELOG.md b/packages/expo-audio/CHANGELOG.md index 65ac4d05d611d9..3659ac8b391d18 100644 --- a/packages/expo-audio/CHANGELOG.md +++ b/packages/expo-audio/CHANGELOG.md @@ -29,6 +29,7 @@ - [iOS] Activate the audio session once and keep it active instead of toggling. ([#48588](https://github.com/expo/expo/pull/48588) by [@alanjhughes](https://github.com/alanjhughes)) - [Android] Fix `createAudioPlayer`/`useAudioPlayer` throwing "Received 5 arguments, but 4 was expected" due to the native `AudioPlayer` constructor missing the iOS-only `allowsExternalPlayback` parameter. ([#48655](https://github.com/expo/expo/pull/48655) by [@RasmusKard](https://github.com/RasmusKard)) - [iOS] Report `denied` instead of crashing the app when `NSMicrophoneUsageDescription` is missing. ([#48840](https://github.com/expo/expo/pull/48840) by [@ahmadaccino](https://github.com/ahmadaccino)) +- [iOS] Resolve permission requests with `denied` and reject recording calls instead of letting the OS terminate the app when `NSMicrophoneUsageDescription` is missing. ([#49162](https://github.com/expo/expo/pull/49162) by [@alanjhughes](https://github.com/alanjhughes)) ### 💡 Others diff --git a/packages/expo-audio/ios/AudioExceptions.swift b/packages/expo-audio/ios/AudioExceptions.swift index 12d82003955e0f..99a5aa26f0f7c0 100644 --- a/packages/expo-audio/ios/AudioExceptions.swift +++ b/packages/expo-audio/ios/AudioExceptions.swift @@ -18,6 +18,12 @@ internal final class AudioPermissionsException: Exception { } } +internal final class MicrophoneUsageDescriptionException: Exception { + override var reason: String { + "Cannot record audio because NSMicrophoneUsageDescription is missing from the app's Info.plist" + } +} + internal final class InvalidAudioModeException: GenericException { override var reason: String { "Impossible audio mode: \(param)" diff --git a/packages/expo-audio/ios/AudioModule.swift b/packages/expo-audio/ios/AudioModule.swift index e535c1b42ddb4e..bad776eb03020e 100644 --- a/packages/expo-audio/ios/AudioModule.swift +++ b/packages/expo-audio/ios/AudioModule.swift @@ -485,6 +485,7 @@ public class AudioModule: Module { } AsyncFunction("prepareToRecordAsync") { (recorder, options: RecordingOptions?) in + try AudioRecordingRequester.requireUsageDescription() let deactivateSessionOnFailure = sessionQueue.sync { !sessionIsActive && audioSessionActivityKeepers.isEmpty } @@ -1017,6 +1018,8 @@ public class AudioModule: Module { private func checkPermissions() throws { #if os(iOS) + try AudioRecordingRequester.requireUsageDescription() + if #available(iOS 17.0, *) { switch AVAudioApplication.shared.recordPermission { case .denied, .undetermined: diff --git a/packages/expo-audio/ios/AudioRecordingRequester.swift b/packages/expo-audio/ios/AudioRecordingRequester.swift index fa50882a9f053b..e813e521d62f9e 100644 --- a/packages/expo-audio/ios/AudioRecordingRequester.swift +++ b/packages/expo-audio/ios/AudioRecordingRequester.swift @@ -7,10 +7,14 @@ public class AudioRecordingRequester: NSObject, EXPermissionsRequester { return "audioRecording" } + static var microphoneUsageDescription: Any? { + Bundle.main.infoDictionary?["NSMicrophoneUsageDescription"] + } + public func getPermissions() -> [AnyHashable: Any] { return Self.permissions( systemStatus: AVAudioSession.sharedInstance().recordPermission, - usageDescription: Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") + usageDescription: Self.microphoneUsageDescription ) } @@ -45,7 +49,22 @@ public class AudioRecordingRequester: NSObject, EXPermissionsRequester { ] } + static func requireUsageDescription(_ usageDescription: Any? = AudioRecordingRequester.microphoneUsageDescription) throws { + guard usageDescription != nil else { + throw MicrophoneUsageDescriptionException() + } + } + public func requestPermissions(resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: @escaping EXPromiseRejectBlock) { + requestPermissions(usageDescription: Self.microphoneUsageDescription, resolver: resolve, rejecter: reject) + } + + func requestPermissions(usageDescription: Any?, resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: @escaping EXPromiseRejectBlock) { + guard usageDescription != nil else { + resolve(Self.permissions(systemStatus: AVAudioSession.sharedInstance().recordPermission, usageDescription: nil)) + return + } + typealias PermissionRequestFunction = @convention(c) (AnyObject, Selector, @escaping (Bool) -> Void) -> Void let recordPermissionSelector = NSSelectorFromString(selector.joined()) diff --git a/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift b/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift index a024f734c9a399..e278ac6f9b21db 100644 --- a/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift +++ b/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift @@ -30,5 +30,31 @@ struct AudioRecordingRequesterTests { #expect(permissions["status"] as? UInt32 == expected.rawValue) } + + @Test + func `resolves denied without a system request when the usage description is missing`() { + let requester = AudioRecordingRequester() + var resolved: [AnyHashable: Any]? + + requester.requestPermissions(usageDescription: nil) { result in + resolved = result as? [AnyHashable: Any] + } rejecter: { _, _, _ in + Issue.record("requestPermissions rejected instead of resolving") + } + + #expect(resolved?["status"] as? UInt32 == EXPermissionStatusDenied.rawValue) + } + + @Test + func `requireUsageDescription throws when the usage description is missing`() { + #expect(throws: MicrophoneUsageDescriptionException.self) { + try AudioRecordingRequester.requireUsageDescription(nil) + } + } + + @Test + func `requireUsageDescription accepts a present usage description`() throws { + try AudioRecordingRequester.requireUsageDescription("Allow $(PRODUCT_NAME) to access your microphone") + } } #endif From d79df01b6957a5b04a279a6677adcb39416a2a95 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Wed, 26 Aug 2026 15:55:21 +0100 Subject: [PATCH 16/17] fix(babel-preset-expo): Move leftover `transform-object-rest-spread` (address missed computed property exclusion) (#49278) # Why Resolves #49232 Related #45337 Previously, the split of `babel-preset-expo` into separate sub-presets missed the `@babel/transform-object-rest-spread` plugin. I've likely left it alone since I was unable to confirm that moving it wouldn't cause the order-dependent issues the bug details. This plugin should instead be only exercised for Hermes v0 and Webviews, not for the Hermes v1 and Modern Web presets. It should be safely movable assuming that it's added after `@babel/plugin-transform-destructuring` (to avoid the ordering dependent bug). This is safe as long as we trust that Hermes does not have any bugs/quirks in its implementation. # How - Move `transform-object-rest-spread` to individual `hermes-v0` and `webview` presets - Note added to capture order dependence - Update noxcturnal transformer config to mirror changes (main-only, no changelog entry) # Test Plan - Unit tests added to capture transform bug related to #49232 - Unit tests added to capture plugin change in configs - `test-suite` updated `JSDestructuring` with all relevant cases for `transform-object-rest-spread` - **Note:** This is fully LLM-derived but looks comprehensive to me - These tests have passed on a test run of `E2E_FORCE_BABEL=1 expo start --clear` against a clean iOS simulator build - Agent confirmed that the iOS bundle does not contain `transform-object-rest-spread` transform outputs and instead contains raw rest-spread patterns (Asked to validate with `pnpx 2g` and fetch the raw iOS bundle identically to the simulator) # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- apps/test-suite/tests/JSDestructuring.js | 397 ++++++++++++++++++ .../__tests__/noxcturnal/expo-plugins.test.ts | 107 ++++- .../noxcturnal/configs/hermes-v0.ts | 1 + .../noxcturnal/configs/types.ts | 1 + .../noxcturnal/configs/webview.ts | 1 + .../noxcturnal/noxcturnal-transformer.ts | 16 +- packages/babel-preset-expo/CHANGELOG.md | 1 + .../src/__tests__/hermes-bytecode.test.ts | 6 +- .../src/__tests__/object-rest-spread.test.ts | 67 +++ .../src/__tests__/preset-config.test.ts | 31 +- .../babel-preset-expo/src/configs/expo.ts | 12 - .../src/configs/hermes-v0.ts | 3 + .../babel-preset-expo/src/configs/webview.ts | 3 + 13 files changed, 598 insertions(+), 48 deletions(-) create mode 100644 packages/babel-preset-expo/src/__tests__/object-rest-spread.test.ts diff --git a/apps/test-suite/tests/JSDestructuring.js b/apps/test-suite/tests/JSDestructuring.js index 414ad8208f86c2..a640c066f181e6 100644 --- a/apps/test-suite/tests/JSDestructuring.js +++ b/apps/test-suite/tests/JSDestructuring.js @@ -8,6 +8,9 @@ export const name = 'JS Destructuring'; +// Keep object rest in an exported declaration to cover the export-specific transform path. +export const { exportedValue, ...exportedRest } = { exportedValue: 1, exportedOther: 2 }; + export function test({ describe, it, expect }) { describe('JS Destructuring', () => { describe('object patterns', () => { @@ -79,6 +82,11 @@ export function test({ describe, it, expect }) { expect(rest).toEqual({}); }); + it('collects a rest-only object pattern', () => { + const { ...copy } = { a: 1, b: 2 }; + expect(copy).toEqual({ a: 1, b: 2 }); + }); + it('handles nested object patterns', () => { const { a: { b }, @@ -93,6 +101,33 @@ export function test({ describe, it, expect }) { expect(b).toBe(99); }); + it('collects nested object rest at multiple levels', () => { + const { + outer: { + inner: { selected, ...innerRest }, + ...outerRest + }, + ...rootRest + } = { + outer: { inner: { selected: 1, kept: 2 }, sibling: 3 }, + root: 4, + }; + expect(selected).toBe(1); + expect(innerRest).toEqual({ kept: 2 }); + expect(outerRest).toEqual({ sibling: 3 }); + expect(rootRest).toEqual({ root: 4 }); + }); + + it('collects object rest behind a computed nested object reference', () => { + let key = 0; + const { + [key++]: { selected, ...rest }, + } = { 0: { selected: 1, kept: 2 } }; + expect(key).toBe(1); + expect(selected).toBe(1); + expect(rest).toEqual({ kept: 2 }); + }); + it('handles shorthand for prototype properties', () => { const { toString } = {}; expect(typeof toString).toBe('function'); @@ -143,6 +178,76 @@ export function test({ describe, it, expect }) { expect(rest).toEqual({}); }); + // From: transform-object-rest-spread/object-rest/remove-unused-computed-key-loose/exec.js + // Regression test for https://github.com/babel/babel/pull/18197 + it('object rest preserves an unused computed-key exclusion', () => { + function omit(obj, spec) { + const { [spec.key]: unused, ...rest } = obj; + return rest; + } + + expect(omit({ a: 1, b: 2 }, { key: 'a' })).toEqual({ b: 2 }); + }); + + it('object rest excludes unused computed element and call keys', () => { + const spec = { keys: ['b'] }; + const getKey = () => 'c'; + const { + [spec.keys[0]]: _element, + [getKey()]: _call, + ...rest + } = { + b: 2, + c: 3, + keep: 4, + }; + expect(rest).toEqual({ keep: 4 }); + }); + + it('object rest excludes computed symbol keys and retains other symbols', () => { + const excluded = Symbol('excluded'); + const retained = Symbol('retained'); + const { [excluded]: _excluded, ...rest } = { [excluded]: 1, [retained]: 2, kept: 3 }; + expect(rest[excluded]).toBeUndefined(); + expect(rest[retained]).toBe(2); + expect(rest.kept).toBe(3); + }); + + it('object rest coerces non-string computed exclusion keys', () => { + const keys = [null, undefined, true, false]; + const source = { null: 1, undefined: 2, true: 3, false: 4, kept: 5 }; + const { + [keys[0]]: nullValue, + [keys[1]]: undefinedValue, + [keys[2]]: trueValue, + [keys[3]]: falseValue, + ...rest + } = source; + expect([nullValue, undefinedValue, trueValue, falseValue]).toEqual([1, 2, 3, 4]); + expect(rest).toEqual({ kept: 5 }); + }); + + it('object rest excludes template-literal computed keys', () => { + const prefix = 'user'; + const { [`${prefix}_name`]: name, ...rest } = { user_name: 'Ada', role: 'admin' }; + expect(name).toBe('Ada'); + expect(rest).toEqual({ role: 'admin' }); + }); + + it('evaluates an excluded getter even when its binding is unused', () => { + let called = 0; + const source = { + get excluded() { + called++; + return 1; + }, + kept: 2, + }; + const { excluded: unused, ...rest } = source; + expect(called).toBe(1); + expect(rest).toEqual({ kept: 2 }); + }); + // From: destructuring/object-rest-impure-computed-keys/exec.js it('object rest with impure computed keys', () => { var key, x, y, z; @@ -187,6 +292,95 @@ export function test({ describe, it, expect }) { var {} = null; }).toThrow(); }); + + it('rest-only object pattern throws on null and undefined', () => { + expect(() => { + const { ...rest } = null; + }).toThrow(); + expect(() => { + const { ...rest } = undefined; + }).toThrow(); + }); + }); + + describe('object spread', () => { + it('copies properties and applies sources from left to right', () => { + const result = { + first: 1, + shared: 'initial', + ...{ second: 2, shared: 'middle' }, + shared: 'last', + }; + expect(result).toEqual({ first: 1, second: 2, shared: 'last' }); + }); + + it('combines multiple spread sources', () => { + const result = { ...{ a: 1 }, b: 2, ...{ c: 3 }, ...{ d: 4 } }; + expect(result).toEqual({ a: 1, b: 2, c: 3, d: 4 }); + }); + + it('transforms object spread in assignment and nested expression contexts', () => { + let assigned; + assigned = { before: 1, ...{ middle: 2 }, after: 3 }; + const nested = { value: { ...assigned, final: 4 } }; + expect(assigned).toEqual({ before: 1, middle: 2, after: 3 }); + expect(nested).toEqual({ value: { before: 1, middle: 2, after: 3, final: 4 } }); + }); + + it('evaluates spread source expressions once from left to right', () => { + const calls = []; + const source = (name, value) => { + calls.push(name); + return value; + }; + const result = { ...source('first', { a: 1 }), ...source('second', { b: 2 }) }; + expect(result).toEqual({ a: 1, b: 2 }); + expect(calls).toEqual(['first', 'second']); + }); + + it('ignores null and undefined spread sources', () => { + expect({ before: 1, ...null, ...undefined, after: 2 }).toEqual({ before: 1, after: 2 }); + }); + + it('copies own enumerable properties but not inherited or non-enumerable properties', () => { + const source = Object.create({ inherited: 1 }); + source.enumerable = 2; + Object.defineProperty(source, 'hidden', { enumerable: false, value: 3 }); + const result = { ...source }; + expect(result).toEqual({ enumerable: 2 }); + expect(result.inherited).toBeUndefined(); + expect(result.hidden).toBeUndefined(); + }); + + it('copies enumerable symbol properties', () => { + const symbol = Symbol('spread'); + const result = { ...{ visible: 1, [symbol]: 2 } }; + expect(result.visible).toBe(1); + expect(result[symbol]).toBe(2); + }); + + it('evaluates spread getters once and in source order', () => { + const order = []; + const first = { + get a() { + order.push('first'); + return 1; + }, + }; + const second = { + get b() { + order.push('second'); + return 2; + }, + }; + expect({ ...first, ...second }).toEqual({ a: 1, b: 2 }); + expect(order).toEqual(['first', 'second']); + }); + + it('spreads enumerable properties from primitive values', () => { + expect({ ...'abc' }).toEqual({ 0: 'a', 1: 'b', 2: 'c' }); + expect({ ...42, ...true }).toEqual({}); + }); }); describe('array patterns', () => { @@ -439,6 +633,22 @@ export function test({ describe, it, expect }) { expect(tail).toEqual([2, 3]); expect(meta).toEqual({ type: 'test', version: 2 }); }); + + it('object rest nested inside array declaration and assignment patterns', () => { + const [first, { selected, ...declaredRest }] = [1, { selected: 2, kept: 3 }]; + let assignedFirst, assignedValue, assignedRest; + [assignedFirst, { selected: assignedValue, ...assignedRest }] = [ + 4, + { selected: 5, kept: 6 }, + ]; + expect([first, selected, declaredRest]).toEqual([1, 2, { kept: 3 }]); + expect([assignedFirst, assignedValue, assignedRest]).toEqual([4, 5, { kept: 6 }]); + }); + + it('supports object rest nested in an array rest target', () => { + const [...{ ...properties }] = ['a', 'b']; + expect(properties).toEqual({ 0: 'a', 1: 'b' }); + }); }); describe('function parameters', () => { @@ -507,6 +717,25 @@ export function test({ describe, it, expect }) { expect(f({ x: 10 })).toBe(12); }); + it('makes object rest available to a later parameter default', () => { + function f({ selected, ...rest }, value = rest.kept) { + return [selected, rest, value]; + } + expect(f({ selected: 1, kept: 2 })).toEqual([1, { kept: 2 }, 2]); + expect(f({ selected: 1, kept: 2 }, 3)).toEqual([1, { kept: 2 }, 3]); + }); + + it('supports nested object rest in function parameters', () => { + function f({ outer: { selected, ...innerRest }, ...outerRest } = { outer: {} }) { + return [selected, innerRest, outerRest]; + } + expect(f({ outer: { selected: 1, kept: 2 }, root: 3 })).toEqual([ + 1, + { kept: 2 }, + { root: 3 }, + ]); + }); + // From: destructuring/default-precedence/exec.js it('default value references previous params', () => { var f0 = function (a, b = a, c = b) { @@ -584,6 +813,39 @@ export function test({ describe, it, expect }) { expect(result).toEqual({ x: 1, y: 2 }); }); + it('for-of supports object rest in declaration and assignment patterns', () => { + const declared = []; + for (const { selected, ...rest } of [{ selected: 1, kept: 2 }]) { + declared.push([selected, rest]); + } + + let selected, rest; + for ({ selected, ...rest } of [{ selected: 3, kept: 4 }]) { + // Assignment happens in the loop head. + } + expect(declared).toEqual([[1, { kept: 2 }]]); + expect([selected, rest]).toEqual([3, { kept: 4 }]); + }); + + it('for-of supports object rest nested in an array pattern', () => { + const results = []; + for (const [index, { selected, ...rest }] of [[0, { selected: 1, kept: 2 }]]) { + results.push([index, selected, rest]); + } + expect(results).toEqual([[0, 1, { kept: 2 }]]); + }); + + it('for-await-of supports object rest', async () => { + async function* values() { + yield { selected: 1, kept: 2 }; + } + const results = []; + for await (const { selected, ...rest } of values()) { + results.push([selected, rest]); + } + expect(results).toEqual([[1, { kept: 2 }]]); + }); + // NOTE(@kitten): Broken test case // From: destructuring/for-of-shadowed-block-scoped/exec.js // @babel/plugin-transform-block-scoping bug: when it renames the inner @@ -646,6 +908,15 @@ export function test({ describe, it, expect }) { expect(rest).toEqual([2, 3, 4]); }); + it('object-rest assignment returns the right-hand value', () => { + let selected, rest; + const source = { selected: 1, kept: 2 }; + const result = ({ selected, ...rest } = source); + expect(result).toBe(source); + expect(selected).toBe(1); + expect(rest).toEqual({ kept: 2 }); + }); + // From: destructuring/chained/exec.js it('chained destructuring assignment', () => { var a, b, c, d; @@ -679,9 +950,36 @@ export function test({ describe, it, expect }) { } expect(msg).toBe('bad type'); }); + + it('collects object rest from a catch binding', () => { + let code, details; + try { + throw { code: 'E_TEST', message: 'failed', retryable: true }; + } catch ({ code: caughtCode, ...rest }) { + code = caughtCode; + details = rest; + } + expect(code).toBe('E_TEST'); + expect(details).toEqual({ message: 'failed', retryable: true }); + }); + + it('collects nested object rest from a catch binding', () => { + let details; + try { + throw { error: { message: 'failed', code: 42 } }; + } catch ({ error: { message, ...rest } }) { + details = [message, rest]; + } + expect(details).toEqual(['failed', { code: 42 }]); + }); }); describe('declaration variants', () => { + it('supports object rest in exported declarations', () => { + expect(exportedValue).toBe(1); + expect(exportedRest).toEqual({ exportedOther: 2 }); + }); + it('const with object destructuring', () => { const { x, y } = { x: 1, y: 2 }; expect(x).toBe(1); @@ -713,6 +1011,105 @@ export function test({ describe, it, expect }) { }); }); + describe('object-rest-spread regressions', () => { + // Adapted from Babel regression fixture T7178. + it('does not collide with a shadowed outer binding', () => { + const props = { outer: true }; + const inner = function () { + const { ...props } = this.props; + return props; + }.call({ props: { inner: true } }); + expect(props).toEqual({ outer: true }); + expect(inner).toEqual({ inner: true }); + }); + + // Adapted from Babel regression fixture gh-17274. + it('preserves computed-key order through nested object rest', () => { + const order = []; + const key = (value) => { + order.push(value); + return 'x'; + }; + const { + [key(0)]: { [key(1)]: first, [key(2)]: second, ...rest }, + [key(3)]: outer, + } = { x: { x: {} } }; + expect(order).toEqual([0, 1, 2, 3]); + expect(rest).toEqual({}); + expect(outer).toEqual({ x: {} }); + }); + + // Adapted from Babel regression fixture gh-4904. + it('supports nested rest and rest inside a callback parameter', () => { + const receive = (value, callback) => callback(value); + const { + nested: { selected, ...nestedRest }, + ...outerRest + } = { nested: { selected: 1, kept: 2 }, root: 3 }; + const callbackResult = receive({ selected: 4, kept: 5 }, ({ selected, ...rest }) => [ + selected, + rest, + ]); + expect([selected, nestedRest, outerRest]).toEqual([1, { kept: 2 }, { root: 3 }]); + expect(callbackResult).toEqual([4, { kept: 5 }]); + }); + + // Adapted from Babel regression fixture gh-5151. + it('makes rest bindings available to later declarators and preserves declarator order', () => { + const order = []; + const record = (name, value) => { + order.push(name); + return value; + }; + const { selected, ...rest } = record('source', { selected: 1, kept: 2 }), + later = record('later', rest.kept); + var before = record('before', true), + { + nested: { value, ...nestedRest }, + ...outerRest + } = record('nested', { nested: { value: 3, kept: 4 }, root: 5 }), + after = record('after', true); + expect([selected, rest, later]).toEqual([1, { kept: 2 }, 2]); + expect([value, nestedRest, outerRest]).toEqual([3, { kept: 4 }, { root: 5 }]); + expect([before, after]).toEqual([true, true]); + expect(order).toEqual(['source', 'later', 'before', 'nested', 'after']); + }); + + // Adapted from Babel regression fixture gh-7388. + it('transforms object rest inside deeply nested default arrow functions', () => { + function outer(value) { + const { + first = (firstValue = {}) => { + const { + second = (secondValue = {}) => { + const { selected, ...rest } = secondValue; + return [selected, rest]; + }, + } = firstValue; + return second; + }, + } = value; + return first; + } + const first = outer({}); + const second = first({}); + expect(second({ selected: 1, kept: 2 })).toEqual([1, { kept: 2 }]); + }); + + // Adapted from Babel regression fixture gh-8323. + it('preserves defaults when an object parameter also contains rest', () => { + let defaults = 0; + const fallback = () => { + defaults++; + return 3; + }; + const extract = ({ a = fallback(), b, c, ...rest }) => [a, b, c, rest]; + expect(extract({ b: 2, c: 3, kept: 4 })).toEqual([3, 2, 3, { kept: 4 }]); + expect(extract({ a: 1, b: 2, c: 3, kept: 4 })).toEqual([1, 2, 3, { kept: 4 }]); + expect(defaults).toBe(1); + }); + }); + // From: destructuring/check-iterator-return/exec.js describe('iterator protocol', () => { it('empty pattern calls iterator.return when return() returns object', () => { diff --git a/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/expo-plugins.test.ts b/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/expo-plugins.test.ts index d84cde1ee5a170..2a0f1ca812433c 100644 --- a/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/expo-plugins.test.ts +++ b/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/expo-plugins.test.ts @@ -704,19 +704,104 @@ it('uses the Babel WebView preflight for DOM components', async () => { expect(result.result.code).toContain('decorate'); }); -it('matches Babel by lowering object spread for a modern non-Hermes target', async () => { - const result = await transformFileFullyWithNoxcturnal({ - filename: '/app/src/web.js', - projectRoot: '/app', - source: 'module.exports = { ...source, value: 1 };', - options: options({ platform: 'web', customTransformOptions: { engine: undefined } }), - isDefaultExpoTransformer: true, - config: fullConfig(), +describe('object rest/spread transform profiles', () => { + it.each([ + ['Hermes v0', options({ unstable_transformProfile: 'default' })], + [ + 'WebView', + options({ + unstable_transformProfile: 'hermes-stable', + customTransformOptions: { engine: 'hermes', dom: 'true' }, + }), + ], + ])('lowers object spread for %s', async (_profile, transformOptions) => { + const result = await transformFileFullyWithNoxcturnal({ + filename: '/app/src/native.js', + projectRoot: '/app', + source: 'module.exports = { ...source, value: 1 };', + options: transformOptions, + isDefaultExpoTransformer: true, + config: fullConfig(), + }); + + expect(result.status).toBe('complete'); + if (result.status !== 'complete') return; + expect(result.result.code).not.toContain('...source'); }); - expect(result.status).toBe('complete'); - if (result.status !== 'complete') return; - expect(result.result.code).not.toContain('...source'); + it.each([ + ['Hermes v1', options({ unstable_transformProfile: 'hermes-stable' })], + [ + 'web', + options({ + platform: 'web', + customTransformOptions: { engine: undefined }, + }), + ], + [ + 'server', + options({ + customTransformOptions: { engine: 'hermes', environment: 'node' }, + }), + ], + ])('preserves object spread for %s', async (_profile, transformOptions) => { + const result = await transformFileFullyWithNoxcturnal({ + filename: '/app/src/modern.js', + projectRoot: '/app', + source: 'module.exports = { ...source, value: 1 };', + options: transformOptions, + isDefaultExpoTransformer: true, + config: fullConfig(), + }); + + expect(result.status).toBe('complete'); + if (result.status !== 'complete') return; + expect(result.result.code).toContain('...source'); + }); + + it.each([ + ['Hermes v0', options({ unstable_transformProfile: 'default' })], + [ + 'WebView', + options({ + unstable_transformProfile: 'hermes-stable', + customTransformOptions: { engine: 'hermes', dom: 'true' }, + }), + ], + ])('preserves computed-key exclusion semantics for %s', async (_profile, transformOptions) => { + const result = await transformFileFullyWithNoxcturnal({ + filename: '/app/src/native.js', + projectRoot: '/app', + source: `function omit(obj, spec) { + const { [spec.key]: unused, ...rest } = obj; + return rest; + } + module.exports = omit({ a: 1, b: 2 }, { key: 'a' });`, + options: transformOptions, + isDefaultExpoTransformer: true, + config: fullConfig(), + }); + + expect(result.status).toBe('complete'); + if (result.status !== 'complete') return; + expect(result.result.code).not.toContain('...rest'); + + let factory: Function | undefined; + new Function('__d', result.result.code)((value: Function) => { + factory = value; + }); + const module = { exports: {} as unknown }; + factory?.( + globalThis, + (_id: unknown, name: string) => requireFromBabelPresetExpo(name), + (_id: unknown, name: string) => requireFromBabelPresetExpo(name), + (_id: unknown, name: string) => requireFromBabelPresetExpo(name), + module, + module.exports, + [] + ); + expect(module.exports).toEqual({ b: 2 }); + }); }); it('matches Babel by preserving async generators on modern web targets', async () => { diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/hermes-v0.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/hermes-v0.ts index ae81ba6bc49622..20a4e4eab1a088 100644 --- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/hermes-v0.ts +++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/hermes-v0.ts @@ -13,6 +13,7 @@ export function getHermesV0PreflightConfig(facts: ProfilePreflightFacts): Profil destructuring: true, asyncGeneratorFunctions: facts.hasAsyncGenerator, asyncFunctions: facts.hasAsync, + objectRestSpread: facts.hasSpread ? { loose: true, useBuiltIns: true } : undefined, parameters: true, }; } diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/types.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/types.ts index 9d19e87f84c801..2c889a65f20a52 100644 --- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/types.ts +++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/types.ts @@ -8,6 +8,7 @@ export interface ProfilePreflightFacts { hasForOf: boolean; hasPrivateSyntax: boolean; hasRegexpLiteral: boolean; + hasSpread: boolean; hasStaticBlock: boolean; } diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/webview.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/webview.ts index 4aa6934f2cb53a..3279252dbdfd1c 100644 --- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/webview.ts +++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/webview.ts @@ -19,5 +19,6 @@ export function getWebViewPreflightConfig(facts: ProfilePreflightFacts): Profile optionalChaining: { loose: true }, nullishCoalescingOperator: { loose: true }, logicalAssignmentOperators: true, + objectRestSpread: facts.hasSpread ? { loose: true, useBuiltIns: true } : undefined, }; } diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/noxcturnal-transformer.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/noxcturnal-transformer.ts index 79d64f30cf8166..ae00e99c4d7e34 100644 --- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/noxcturnal-transformer.ts +++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/noxcturnal-transformer.ts @@ -678,8 +678,6 @@ function createProfilePreflight( const hasAsyncCandidate = sourceFacts.hasAsync; const hasRegexpLiteralCandidate = sourceFacts.hasSlash; const hasDecoratorCandidate = /@\w/.test(input.source); - const nonHermes = options.customTransformOptions?.engine !== 'hermes'; - const hasObjectRestSpread = nonHermes && hasSpreadCandidate; const profileTransforms = getProfilePreflightConfig(input, { hasAsync: hasAsyncCandidate, hasAsyncGenerator, @@ -688,15 +686,12 @@ function createProfilePreflight( hasForOf: hasForOfCandidate, hasPrivateSyntax: sourceFacts.hasPrivateSyntax, hasRegexpLiteral: hasRegexpLiteralCandidate, + hasSpread: hasSpreadCandidate, hasStaticBlock, }); const hasProfileWork = Object.values(profileTransforms).some(Boolean); const hasOtherLanguageWork = - isTypeScript || - enableReactRefresh || - hasProfileWork || - hasDecoratorCandidate || - hasObjectRestSpread; + isTypeScript || enableReactRefresh || hasProfileWork || hasDecoratorCandidate; // Avoid a parse/codegen boundary for the overwhelmingly common file that has // none of this recipe's language work. Flow without JSX is already rendered by // its focused native erasure and does not need a second print. @@ -706,7 +701,6 @@ function createProfilePreflight( !mayContainJsx && !hasProfileWork && !hasDecoratorCandidate && - !hasObjectRestSpread && !enableReactRefresh ) { return null; @@ -715,12 +709,6 @@ function createProfilePreflight( transforms: { ...profileTransforms, legacyDecorators: hasDecoratorCandidate, - objectRestSpread: hasObjectRestSpread - ? { - loose: true, - useBuiltIns: true, - } - : undefined, }, // Expo owns this recipe. These choices mirror its Hermes-v1 and React configs // without exposing either config name through Noxcturnal's capability API. diff --git a/packages/babel-preset-expo/CHANGELOG.md b/packages/babel-preset-expo/CHANGELOG.md index 1eaedcdb41b193..18e525045b5834 100644 --- a/packages/babel-preset-expo/CHANGELOG.md +++ b/packages/babel-preset-expo/CHANGELOG.md @@ -12,6 +12,7 @@ - Escape backslashes when serializing `'widget'` functions so escape sequences like `\n` in a widget layout survive the template-literal round-trip instead of corrupting the stored function and crashing the widget with `SyntaxError: Unexpected EOF`. ([#47626](https://github.com/expo/expo/pull/47626) by [@alecmolloy](https://github.com/alecmolloy)) - Fix legacy decorators on class properties when corresponding class transforms are disabled, which the decorators plugin relies on ([#47724](https://github.com/expo/expo/pull/47724) by [@Gitarcitano](https://github.com/Gitarcitano), [@kitten](https://github.com/kitten)) +- Disable `@babel/plugin-transform-object-rest-spread` in Hermes v1 and Modern Web sub-presets. The ordering dependence on `@babel/plugin-transform-destructuring` could cause computed exclusion to be missed ([#49278](https://github.com/expo/expo/pull/49278) by [@kitten](https://github.com/kitten)) ### 💡 Others diff --git a/packages/babel-preset-expo/src/__tests__/hermes-bytecode.test.ts b/packages/babel-preset-expo/src/__tests__/hermes-bytecode.test.ts index 0de98ad24ffd14..c3f0819f815f71 100644 --- a/packages/babel-preset-expo/src/__tests__/hermes-bytecode.test.ts +++ b/packages/babel-preset-expo/src/__tests__/hermes-bytecode.test.ts @@ -281,8 +281,10 @@ const LANGUAGE_SAMPLES: { code: `var y = {}; var x = 1; var k = { x, ...y };`, - getCompiledCode() { - return `var y={};var x=1;var k={x,...y};`; + getCompiledCode({ platform }) { + return platform === 'web' + ? `var y={};var x=1;var k={x,...y};` + : `var y={};var x=1;var k=Object.assign({x},y);`; }, }, { diff --git a/packages/babel-preset-expo/src/__tests__/object-rest-spread.test.ts b/packages/babel-preset-expo/src/__tests__/object-rest-spread.test.ts new file mode 100644 index 00000000000000..8fe2cdf7014cfc --- /dev/null +++ b/packages/babel-preset-expo/src/__tests__/object-rest-spread.test.ts @@ -0,0 +1,67 @@ +import * as babel from '@babel/core'; + +import preset from '..'; + +jest.mock('../utils/resolveModule.ts', () => ({ + ...jest.requireActual('../utils/resolveModule.ts'), + resolveModule: jest.fn(() => null), + hasModule: jest.fn(() => false), +})); + +const profiles = [ + ['web', { name: 'metro', platform: 'web', isDev: true }, false], + ['hermes-v1', { name: 'metro', platform: 'ios', engine: 'hermes', isDev: true }, false], + ['hermes-v0', { name: 'metro', platform: 'ios', isDev: true }, true], + ['webview', { name: 'metro', platform: 'web', isDomComponent: true, isDev: true }, true], +] as const; + +function transformObjectRest(caller: Record): string { + const result = babel.transformSync( + ` + function omit(obj, spec) { + const { [spec.key]: unused, ...rest } = obj; + return rest; + } + `, + { + babelrc: false, + configFile: false, + filename: '/test.js', + presets: [[preset, { enableBabelRuntime: false }]], + caller: caller as babel.TransformCaller, + } + ); + if (!result?.code) throw new Error('Babel transform returned no code'); + return result.code; +} + +describe('object-rest-spread transforms', () => { + it.each(profiles)('%s selects the expected transform', (_name, caller, transformsObjectRest) => { + const code = transformObjectRest(caller); + + expect(code.includes('...rest')).toBe(!transformsObjectRest); + expect(code.includes('objectWithoutProperties')).toBe(transformsObjectRest); + }); +}); + +describe('computed object-rest exclusions', () => { + it.each(profiles.filter(([, , transformsObjectRest]) => transformsObjectRest))( + '%s assigns the computed key temporary before the loose object-rest transform', + (_name, caller) => { + const code = transformObjectRest(caller); + + expect(code).toMatch(/var _spec\$key\s*=\s*spec\.key/); + expect(code).not.toMatch(/var _spec\$key\s*;/); + } + ); + + it.each(profiles)('%s preserves the computed exclusion key', (_name, caller) => { + const code = transformObjectRest(caller); + const omit = new Function(`${code}; return omit;`)() as ( + obj: Record, + spec: { key: string } + ) => Record; + + expect(omit({ a: 1, b: 2 }, { key: 'a' })).toEqual({ b: 2 }); + }); +}); diff --git a/packages/babel-preset-expo/src/__tests__/preset-config.test.ts b/packages/babel-preset-expo/src/__tests__/preset-config.test.ts index eba67dd15466a4..946cb4ed4238bf 100644 --- a/packages/babel-preset-expo/src/__tests__/preset-config.test.ts +++ b/packages/babel-preset-expo/src/__tests__/preset-config.test.ts @@ -60,7 +60,6 @@ describe('plugin list snapshots', () => { "transform-flow-strip-types", "transform-typescript", "base$0$2$0", - "transform-object-rest-spread", "expo-define-globals", "expo-inline-or-reference-env-vars", "expo-inline-manifest-plugin", @@ -88,6 +87,7 @@ describe('plugin list snapshots', () => { "transform-destructuring", "transform-async-generator-functions", "transform-async-to-generator", + "transform-object-rest-spread", "transform-parameters", "transform-react-display-name", "transform-runtime", @@ -213,7 +213,6 @@ describe('plugin list snapshots', () => { "transform-flow-enums", "transform-flow-strip-types", "transform-typescript", - "transform-object-rest-spread", "expo-define-globals", "expo-inline-or-reference-env-vars", "Rewrite react-native to react-native-web", @@ -259,7 +258,6 @@ describe('plugin list snapshots', () => { "transform-flow-enums", "transform-flow-strip-types", "transform-typescript", - "transform-object-rest-spread", "expo-define-globals", "expo-minify-platform-select", "expo-inline-or-reference-env-vars", @@ -307,7 +305,6 @@ describe('plugin list snapshots', () => { "transform-flow-enums", "transform-flow-strip-types", "transform-typescript", - "transform-object-rest-spread", "expo-define-globals", "expo-inline-manifest-plugin", "expo-router", @@ -352,7 +349,6 @@ describe('plugin list snapshots', () => { "transform-flow-enums", "transform-flow-strip-types", "transform-typescript", - "transform-object-rest-spread", "expo-define-globals", "expo-inline-or-reference-env-vars", "Rewrite react-native to react-native-web", @@ -400,7 +396,6 @@ describe('plugin list snapshots', () => { "transform-flow-strip-types", "transform-typescript", "base$0$2$0", - "transform-object-rest-spread", "expo-define-globals", "expo-inline-or-reference-env-vars", "Rewrite react-native to react-native-web", @@ -436,6 +431,7 @@ describe('plugin list snapshots', () => { "transform-optional-chaining", "transform-nullish-coalescing-operator", "transform-logical-assignment-operators", + "transform-object-rest-spread", "transform-runtime", "transform-export-namespace-from", "proposal-export-default-from", @@ -560,13 +556,23 @@ describe('isDomComponent', () => { }); }); -describe('engine-driven plugins', () => { - it('adds transform-object-rest-spread for non-hermes engine', () => { +describe('profile-driven plugins', () => { + it('adds transform-object-rest-spread for the hermes-v0 profile', () => { const keys = getPluginKeys({ name: 'metro', platform: 'ios', isDev: true }); expect(keys).toContain('transform-object-rest-spread'); }); - it('does not add transform-object-rest-spread for hermes engine', () => { + it('adds transform-object-rest-spread for the webview profile', () => { + const keys = getPluginKeys({ + name: 'metro', + platform: 'web', + isDomComponent: true, + isDev: true, + }); + expect(keys).toContain('transform-object-rest-spread'); + }); + + it('does not add transform-object-rest-spread for the hermes-v1 profile', () => { const keys = getPluginKeys({ name: 'metro', engine: 'hermes', @@ -576,6 +582,13 @@ describe('engine-driven plugins', () => { expect(keys).not.toContain('transform-object-rest-spread'); }); + it.each([ + ['web', { name: 'metro', platform: 'web', isDev: true }], + ['server', { name: 'metro', platform: 'ios', isServer: true, isDev: true }], + ])('does not add transform-object-rest-spread for the modern %s profile', (_name, caller) => { + expect(getPluginKeys(caller)).not.toContain('transform-object-rest-spread'); + }); + it('adds transform-parameters for default engine (hermes-v0)', () => { const keys = getPluginKeys({ name: 'metro', platform: 'ios', isDev: true }); expect(keys).toContain('transform-parameters'); diff --git a/packages/babel-preset-expo/src/configs/expo.ts b/packages/babel-preset-expo/src/configs/expo.ts index 1a8d7bc17b73d9..314b6ef7e1a5d6 100644 --- a/packages/babel-preset-expo/src/configs/expo.ts +++ b/packages/babel-preset-expo/src/configs/expo.ts @@ -64,18 +64,6 @@ module.exports = function (api: ConfigAPI, options: ExpoConfigOptions) { plugins.push(reactCompilerPlugin); } - // TODO(@kitten): Remove or add non-hermes config - if (options.engine !== 'hermes') { - // `@react-native/babel-preset` configures this plugin with `{ loose: true }`, which breaks all - // getters and setters in spread objects. We need to add this plugin ourself without that option. - // @see https://github.com/expo/expo/pull/11960#issuecomment-887796455 - plugins.push([ - require('@babel/plugin-transform-object-rest-spread'), - // Assume no dependence on getters or evaluation order. See https://github.com/babel/babel/pull/11520 - { loose: true, useBuiltIns: true }, - ]); - } - const inlines = getInlinesFromOptions(options); plugins.push([require('../plugins/define-plugin'), inlines]); diff --git a/packages/babel-preset-expo/src/configs/hermes-v0.ts b/packages/babel-preset-expo/src/configs/hermes-v0.ts index 4c6698145555f9..56505c840bfb0d 100644 --- a/packages/babel-preset-expo/src/configs/hermes-v0.ts +++ b/packages/babel-preset-expo/src/configs/hermes-v0.ts @@ -24,6 +24,9 @@ module.exports = function (_api: ConfigAPI, _options: HermesV0ConfigOptions) { [require('@babel/plugin-transform-destructuring'), { useBuiltIns: true }], [require('@babel/plugin-transform-async-generator-functions')], [require('@babel/plugin-transform-async-to-generator')], + // Keep this after transform-destructuring to avoid the loose-mode computed exclusion bug. + // See: https://github.com/babel/babel/pull/18197 + [require('@babel/plugin-transform-object-rest-spread'), { loose: true, useBuiltIns: true }], // Ensure the react-jsx-dev plugin works as expected when JSX is used in a function body. require('@babel/plugin-transform-parameters'), [require('@babel/plugin-transform-react-display-name')], diff --git a/packages/babel-preset-expo/src/configs/webview.ts b/packages/babel-preset-expo/src/configs/webview.ts index 339ffc32aa5745..47fcaf4607c4b2 100644 --- a/packages/babel-preset-expo/src/configs/webview.ts +++ b/packages/babel-preset-expo/src/configs/webview.ts @@ -33,6 +33,9 @@ module.exports = function (_api: ConfigAPI, _options: WebviewConfigOptions) { [require('@babel/plugin-transform-optional-chaining'), { loose: true }], [require('@babel/plugin-transform-nullish-coalescing-operator'), { loose: true }], [require('@babel/plugin-transform-logical-assignment-operators'), { loose: true }], + // Keep this after transform-destructuring to avoid the loose-mode computed exclusion bug. + // See: https://github.com/babel/babel/pull/18197 + [require('@babel/plugin-transform-object-rest-spread'), { loose: true, useBuiltIns: true }], ] as PluginItem[], }; }; From a24085997e78e9c66b3775fc47fa1be47064caa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Dr=C3=B3=C5=BCd=C5=BC?= <31368152+behenate@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:43:57 +0200 Subject: [PATCH 17/17] [app-intents][ios][1/n] Create `expo-app-intents` module (#47207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why Adds `expo-app-intents module`, which provides a framework for adding App Intent support to Expo apps # How We can't really provide this functionality fully from JS. App Intents are compiled from static swift classes at build time. Therefore our best shot at adding App Intent and Apple Intelligence support with minimal hassle for the users is allowing them to define the Intents via inline modules. We have to define the intents natively, but the data used by the intents can be provided from JS. For example: In a restaurant order use case - the Entities, Shortcuts and Intents "shapes" have to be defined natively, but the individual items can be provided at runtime from JS via `setEntityCatalogAsync`. The general flow of a received AppIntent: - An App Intent runs in the app target and calls `AppIntentDispatcher.shared.dispatch(name:params:)`. - `AppIntentDispatcher` wraps the call as an `AppIntentInvocation` with an id, name, params, and timestamp. - The dispatcher saves it through `AppIntentInvocationStore` first, so the invocation survives even if JS is not running. - If JS is alive, `ExpoAppIntentsModule` receives the invocation from the dispatcher’s async stream and emits an onIntent event. - JS handles live events via `addAppIntentListener()` or `useAppIntents()`, or later reads stored ones with `getPendingInvocationsAsync()`. - After handling, JS calls `removePendingInvocationAsync(id)` or `clearPendingInvocationsAsync()` to prevent reprocessing. - For parameterized intents, JS manages searchable values with `setEntityCatalogAsync()`, which stores entities in `AppIntentEntityStore` and refreshes App Shortcuts. The module provides functions for communication between the native and js side: - addAppIntentListener(listener) - subscribes to live App Intent invocations while JS is running. - useAppIntents(handler) - reads pending invocations on mount and listens for new invocations afterward. - getPendingInvocationsAsync() - returns queued intent invocations that have not been removed yet. - removePendingInvocationAsync(id) - marks one handled invocation as removed from the pending queue. - clearPendingInvocationsAsync() clears all queued pending invocations. - setEntityCatalogAsync(kind, entities) - replaces an entity catalog used by App Intents parameter queries and refreshes shortcuts. - getEntityCatalogAsync(kind) reads the current entity catalog for a given kind. - refreshShortcutsAsync() asks iOS to re-evaluate App Shortcut phrases and parameter values. # Things coming in subsequent PRs - cli tool for easy setup in an existing project. It will also allow setup with a few examples for learning. - Docs - Examples for NCL # Things we may add in the future - Apple on-screen intelligence support for ExpoUI via a modifier - I think this should be possible, but I didn't have a chance to try implement it because I'm still on the waitlist for the new Siri, so I would have no way of testing it. - Non main-target App Intents - For some quick actions that don't require an immediate action of the app it's not worth it to spin up the main app target # Test Plan Tested on iPhone 13 and iPhone 17 in BareExpo and a separate test app --------- Co-authored-by: Claude Opus 5 --- .github/workflows/swift-format.yml | 3 + apps/bare-expo/ios/Podfile.lock | 11 + apps/bare-expo/package.json | 1 + packages/expo-app-intents/.gitignore | 2 + packages/expo-app-intents/.npmignore | 22 ++ packages/expo-app-intents/CHANGELOG.md | 13 + packages/expo-app-intents/README.md | 12 + packages/expo-app-intents/app.plugin.js | 1 + packages/expo-app-intents/babel.config.js | 6 + .../expo-app-intents/expo-module.config.json | 6 + .../ios/AppIntentDispatcher.swift | 104 ++++++++ .../ios/AppIntentEntityStore.swift | 174 +++++++++++++ .../ios/AppIntentInvocation.swift | 34 +++ .../ios/AppIntentInvocationStore.swift | 116 +++++++++ .../expo-app-intents/ios/AppIntentValue.swift | 147 +++++++++++ .../ios/ExpoAppIntents.podspec | 35 +++ .../ios/ExpoAppIntentsModule.swift | 85 ++++++ .../ios/Tests/AppIntentDispatcherTests.swift | 232 +++++++++++++++++ .../ios/Tests/AppIntentEntityStoreTests.swift | 156 +++++++++++ .../Tests/AppIntentInvocationStoreTests.swift | 156 +++++++++++ .../ios/Tests/AppIntentValueTests.swift | 58 +++++ .../ios/Tests/ExpoAppIntentsModuleTests.swift | 64 +++++ packages/expo-app-intents/jest.config.js | 4 + packages/expo-app-intents/oxlint.config.mjs | 2 + packages/expo-app-intents/package.json | 51 ++++ packages/expo-app-intents/plugin/index.d.ts | 1 + packages/expo-app-intents/plugin/index.js | 1 + .../expo-app-intents/plugin/jest.config.js | 1 + .../src/__tests__/withAppIntents.test.ts | 111 ++++++++ packages/expo-app-intents/plugin/src/index.ts | 4 + .../plugin/src/withAppIntents.ts | 129 ++++++++++ .../expo-app-intents/plugin/tsconfig.json | 10 + .../src/ExpoAppIntents.types.ts | 59 +++++ .../src/ExpoAppIntentsModule.ts | 18 ++ .../src/__tests__/index.test.ts | 37 +++ .../__tests__/useAppIntents.test.native.tsx | 154 +++++++++++ packages/expo-app-intents/src/index.ts | 243 ++++++++++++++++++ packages/expo-app-intents/tsconfig.all.json | 11 + packages/expo-app-intents/tsconfig.json | 10 + pnpm-lock.yaml | 31 +++ 40 files changed, 2315 insertions(+) create mode 100644 packages/expo-app-intents/.gitignore create mode 100644 packages/expo-app-intents/.npmignore create mode 100644 packages/expo-app-intents/CHANGELOG.md create mode 100644 packages/expo-app-intents/README.md create mode 100644 packages/expo-app-intents/app.plugin.js create mode 100644 packages/expo-app-intents/babel.config.js create mode 100644 packages/expo-app-intents/expo-module.config.json create mode 100644 packages/expo-app-intents/ios/AppIntentDispatcher.swift create mode 100644 packages/expo-app-intents/ios/AppIntentEntityStore.swift create mode 100644 packages/expo-app-intents/ios/AppIntentInvocation.swift create mode 100644 packages/expo-app-intents/ios/AppIntentInvocationStore.swift create mode 100644 packages/expo-app-intents/ios/AppIntentValue.swift create mode 100644 packages/expo-app-intents/ios/ExpoAppIntents.podspec create mode 100644 packages/expo-app-intents/ios/ExpoAppIntentsModule.swift create mode 100644 packages/expo-app-intents/ios/Tests/AppIntentDispatcherTests.swift create mode 100644 packages/expo-app-intents/ios/Tests/AppIntentEntityStoreTests.swift create mode 100644 packages/expo-app-intents/ios/Tests/AppIntentInvocationStoreTests.swift create mode 100644 packages/expo-app-intents/ios/Tests/AppIntentValueTests.swift create mode 100644 packages/expo-app-intents/ios/Tests/ExpoAppIntentsModuleTests.swift create mode 100644 packages/expo-app-intents/jest.config.js create mode 100644 packages/expo-app-intents/oxlint.config.mjs create mode 100644 packages/expo-app-intents/package.json create mode 100644 packages/expo-app-intents/plugin/index.d.ts create mode 100644 packages/expo-app-intents/plugin/index.js create mode 100644 packages/expo-app-intents/plugin/jest.config.js create mode 100644 packages/expo-app-intents/plugin/src/__tests__/withAppIntents.test.ts create mode 100644 packages/expo-app-intents/plugin/src/index.ts create mode 100644 packages/expo-app-intents/plugin/src/withAppIntents.ts create mode 100644 packages/expo-app-intents/plugin/tsconfig.json create mode 100644 packages/expo-app-intents/src/ExpoAppIntents.types.ts create mode 100644 packages/expo-app-intents/src/ExpoAppIntentsModule.ts create mode 100644 packages/expo-app-intents/src/__tests__/index.test.ts create mode 100644 packages/expo-app-intents/src/__tests__/useAppIntents.test.native.tsx create mode 100644 packages/expo-app-intents/src/index.ts create mode 100644 packages/expo-app-intents/tsconfig.all.json create mode 100644 packages/expo-app-intents/tsconfig.json diff --git a/.github/workflows/swift-format.yml b/.github/workflows/swift-format.yml index 26f749fdc030b6..5731cd7bbab42d 100644 --- a/.github/workflows/swift-format.yml +++ b/.github/workflows/swift-format.yml @@ -10,6 +10,7 @@ on: - scripts/swift-format.sh - packages/expo-age-range/**/*.swift - packages/expo-app-integrity/**/*.swift + - packages/expo-app-intents/**/*.swift - packages/expo-app-metrics/**/*.swift - packages/expo-application/**/*.swift - packages/expo-asset/**/*.swift @@ -40,6 +41,7 @@ on: - scripts/swift-format.sh - packages/expo-age-range/**/*.swift - packages/expo-app-integrity/**/*.swift + - packages/expo-app-intents/**/*.swift - packages/expo-app-metrics/**/*.swift - packages/expo-application/**/*.swift - packages/expo-asset/**/*.swift @@ -123,6 +125,7 @@ jobs: PACKAGES: >- packages/expo-age-range packages/expo-app-integrity + packages/expo-app-intents packages/expo-app-metrics packages/expo-application packages/expo-asset diff --git a/apps/bare-expo/ios/Podfile.lock b/apps/bare-expo/ios/Podfile.lock index 12633901277b30..51e971d9f26af5 100644 --- a/apps/bare-expo/ios/Podfile.lock +++ b/apps/bare-expo/ios/Podfile.lock @@ -264,6 +264,11 @@ PODS: - ExpoModulesCore - ExpoAppIntegrity (57.0.1): - ExpoModulesCore + - ExpoAppIntents (0.1.0): + - ExpoModulesCore + - ExpoAppIntents/Tests (0.1.0): + - ExpoModulesCore + - ExpoModulesTestCore - ExpoAppleAuthentication (57.0.1): - ExpoModulesCore - ExpoAppMetrics (57.0.7): @@ -3353,6 +3358,8 @@ DEPENDENCIES: - Expo/Tests (from `../../../packages/expo`) - ExpoAgeRange (from `../../../packages/expo-age-range/ios`) - ExpoAppIntegrity (from `../../../packages/expo-app-integrity/ios`) + - ExpoAppIntents (from `../../../packages/expo-app-intents/ios`) + - ExpoAppIntents/Tests (from `../../../packages/expo-app-intents/ios`) - ExpoAppleAuthentication (from `../../../packages/expo-apple-authentication/ios`) - ExpoAppMetrics (from `../../../packages/expo-app-metrics/ios`) - ExpoAppMetrics/Tests (from `../../../packages/expo-app-metrics/ios`) @@ -3603,6 +3610,9 @@ EXTERNAL SOURCES: ExpoAppIntegrity: inhibit_warnings: false :path: "../../../packages/expo-app-integrity/ios" + ExpoAppIntents: + inhibit_warnings: false + :path: "../../../packages/expo-app-intents/ios" ExpoAppleAuthentication: inhibit_warnings: false :path: "../../../packages/expo-apple-authentication/ios" @@ -4038,6 +4048,7 @@ SPEC CHECKSUMS: expo-dev-menu-interface: 7a59e803916fbfd1b96c75ff91ea8dddac7e722f ExpoAgeRange: f30bef82f1a99c097e28f50ed41ca04d5e1cab1c ExpoAppIntegrity: c4f6d2065674ec9744881f8c04d52ade9b6cacd8 + ExpoAppIntents: e4702a43af248c7f72ffe4855635f8d0319d330a ExpoAppleAuthentication: 97d113f0f2680067e6e76d3c9249e7e1fc026b42 ExpoAppMetrics: edb2e75dba50059ba781c56b8ff0824aaeb9e74b ExpoAsset: a76534cf7b762978861dd31708308496c676166f diff --git a/apps/bare-expo/package.json b/apps/bare-expo/package.json index 072a9d0561f0df..b8275ffdd0ebef 100644 --- a/apps/bare-expo/package.json +++ b/apps/bare-expo/package.json @@ -33,6 +33,7 @@ "@shopify/flash-list": "2.0.2", "@shopify/react-native-skia": "2.6.2", "expo": "workspace:*", + "expo-app-intents": "workspace:*", "expo-app-metrics": "workspace:*", "expo-background-fetch": "workspace:*", "expo-brownfield": "workspace:*", diff --git a/packages/expo-app-intents/.gitignore b/packages/expo-app-intents/.gitignore new file mode 100644 index 00000000000000..d95b2cd0947641 --- /dev/null +++ b/packages/expo-app-intents/.gitignore @@ -0,0 +1,2 @@ +# Written next to the tests by UserDefaults(suiteName: #file) during native test runs. +ios/Tests/*.swift.plist diff --git a/packages/expo-app-intents/.npmignore b/packages/expo-app-intents/.npmignore new file mode 100644 index 00000000000000..e0cb9856756941 --- /dev/null +++ b/packages/expo-app-intents/.npmignore @@ -0,0 +1,22 @@ +# @generated by expo-module-scripts + +# Exclude all top-level hidden directories by convention +/.*/ + +# Exclude tarballs generated by `npm pack` +/*.tgz + +__mocks__ +__tests__ +__rsc_tests__ + +/e2e +/e2e-cli +/babel.config.js +/oxlint.config.mjs +/android/src/androidTest/ +/android/src/test/ +/ios/Tests + +CLAUDE.md +CONTRIBUTING.md diff --git a/packages/expo-app-intents/CHANGELOG.md b/packages/expo-app-intents/CHANGELOG.md new file mode 100644 index 00000000000000..3608627b151793 --- /dev/null +++ b/packages/expo-app-intents/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## Unpublished + +### 🛠 Breaking changes + +### 🎉 New features + +- Initial release. ([#47207](https://github.com/expo/expo/pull/47207) by [@behenate](https://github.com/behenate)) + +### 🐛 Bug fixes + +### 💡 Others diff --git a/packages/expo-app-intents/README.md b/packages/expo-app-intents/README.md new file mode 100644 index 00000000000000..f79f080ee1a05e --- /dev/null +++ b/packages/expo-app-intents/README.md @@ -0,0 +1,12 @@ +# expo-app-intents + +Expose Apple App Intents (Siri, Shortcuts, Spotlight, Apple Intelligence) from Expo apps. + +App Intent types must be compiled into the iOS app target. Apple's build-time metadata extraction does not see code in static pods. This package pairs a runtime pod (JS bridge, invocation queue, entity storage) with app-target Swift that you own, placed in an `app-intents/` directory via [Expo Inline Modules](https://docs.expo.dev/modules/inline-modules-tutorial/). + +## Limitations + +- Shortcut phrases are compiled at build time and cannot be created from JavaScript at runtime. Only parameter values are dynamic. +- A single classic App Shortcut phrase can interpolate at most one non-array parameter. +- Maximum 10 App Shortcuts per app; every phrase must include `\(.applicationName)`. +- iOS 16.4+, macOS 13.4+, and tvOS 16.4+. diff --git a/packages/expo-app-intents/app.plugin.js b/packages/expo-app-intents/app.plugin.js new file mode 100644 index 00000000000000..7be0245a4c3e8b --- /dev/null +++ b/packages/expo-app-intents/app.plugin.js @@ -0,0 +1 @@ +module.exports = require('./plugin/build/withAppIntents'); diff --git a/packages/expo-app-intents/babel.config.js b/packages/expo-app-intents/babel.config.js new file mode 100644 index 00000000000000..9d89e131194f49 --- /dev/null +++ b/packages/expo-app-intents/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/packages/expo-app-intents/expo-module.config.json b/packages/expo-app-intents/expo-module.config.json new file mode 100644 index 00000000000000..585075e73e3e5d --- /dev/null +++ b/packages/expo-app-intents/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["ExpoAppIntentsModule"] + } +} diff --git a/packages/expo-app-intents/ios/AppIntentDispatcher.swift b/packages/expo-app-intents/ios/AppIntentDispatcher.swift new file mode 100644 index 00000000000000..414333faddb855 --- /dev/null +++ b/packages/expo-app-intents/ios/AppIntentDispatcher.swift @@ -0,0 +1,104 @@ +import ExpoModulesCore +import Foundation + +/// Ownership token for a single `AppIntentDispatcher.invocationEvents(for:)` subscription. +/// +/// Cancelling the read task alone still lets an old module receive invocations after a JavaScript +/// reload replaces it, so the token prevents it from subscribing at all. +internal final class AppIntentEventSubscription: @unchecked Sendable { + private let isInvalidated = Mutex(false) + + internal var isValid: Bool { + return isInvalidated.withLock { !$0 } + } + + internal func invalidate() { + isInvalidated.withLock { $0 = true } + } +} + +/// The bridge between app-target App Intent code and the Expo runtime. +/// `AppIntent.perform()` implementations call `await AppIntentDispatcher.shared.dispatch(...)`. +/// The dispatcher persists the invocation first, then notifies JS if it is alive. The intents can +/// be queried and handled from the JS side. +public actor AppIntentDispatcher { + public static let shared = AppIntentDispatcher() + + private let store: AppIntentInvocationStore + private var eventContinuations: [Int: AsyncStream.Continuation] = [:] + private var nextSubscriptionKey = 0 + + /// Set by the app-target `AppIntentsSetup` inline module. Must call + /// `AppShortcuts.updateAppShortcutParameters()` on the app's concrete + /// `AppShortcutsProvider` because the pod cannot reference that type. + private var shortcutsRefreshHandler: (@Sendable () async -> Void)? + + internal init(store: sending AppIntentInvocationStore = AppIntentInvocationStore()) { + self.store = store + } + + /// Returns a stream of the invocations dispatched while JavaScript is running. + /// Every subscription gets its own live stream, and the token has no default so a module replaced by + /// a JavaScript reload cannot subscribe. + internal func invocationEvents( + for subscription: AppIntentEventSubscription + ) -> AsyncStream { + guard subscription.isValid, !Task.isCancelled else { + return AsyncStream { continuation in + continuation.finish() + } + } + + nextSubscriptionKey += 1 + let key = nextSubscriptionKey + return AsyncStream { continuation in + eventContinuations[key] = continuation + continuation.onTermination = { _ in + Task { + await self.removeEventContinuation(key: key) + } + } + } + } + + private func removeEventContinuation(key: Int) { + eventContinuations.removeValue(forKey: key) + } + + @discardableResult + public func dispatch(name: String, params: AppIntentParams = [:]) -> String { + let invocation = AppIntentInvocation(name: name, params: params) + store.append(invocation) + for continuation in eventContinuations.values { + continuation.yield(invocation) + } + return invocation.id + } + + // Both of these propagate a storage failure so `ExpoAppIntentsModule` can reject the promise the + // caller is waiting on. A developer sees a rejected promise; the global `log` only reaches OSLog. + internal func pendingInvocations() throws -> [AppIntentInvocation] { + return try store.pending() + } + + internal func removePendingInvocation(id: String) throws { + try store.remove(id: id) + } + + internal func clearPendingInvocations() { + store.clear() + } + + public func setShortcutsRefreshHandler(_ handler: (@Sendable () async -> Void)?) { + shortcutsRefreshHandler = handler + } + + @discardableResult + internal func requestShortcutsRefresh() async -> Bool { + guard let handler = shortcutsRefreshHandler else { + return false + } + await handler() + return true + } +} diff --git a/packages/expo-app-intents/ios/AppIntentEntityStore.swift b/packages/expo-app-intents/ios/AppIntentEntityStore.swift new file mode 100644 index 00000000000000..acf2990770eb76 --- /dev/null +++ b/packages/expo-app-intents/ios/AppIntentEntityStore.swift @@ -0,0 +1,174 @@ +import ExpoModulesCore +import Foundation + +/// An entity exposed to App Intents parameter queries. JS populates catalogs with +/// `setEntityCatalogAsync`; app-target `EntityQuery` implementations read them through +/// `AppIntentEntityStore.shared`. +/// +/// Catalogs are stored in UserDefaults, so they should stay compact. Apps with large +/// datasets should publish only the subset needed for Siri and Shortcuts resolution. +@Record +public struct AppIntentEntityRecord: Codable, Sendable { + var id: String + var title: String + var subtitle: String? + var synonyms: [String] = [] + + public init(id: String, title: String, subtitle: String? = nil) { + self.init(id: id, title: title, subtitle: subtitle, synonyms: []) + } +} + +public actor AppIntentEntityStore { + public static let shared = AppIntentEntityStore() + internal static let userDefaultsSuiteName = "dev.expo.appintents" + + private let suiteName: String + private var defaults: UserDefaults? + + internal init(userDefaultsSuiteName: String = AppIntentEntityStore.userDefaultsSuiteName) { + suiteName = userDefaultsSuiteName + } + + private func requireDefaults() throws -> UserDefaults { + if let defaults { + return defaults + } + + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw AppIntentEntityStoreUnavailableException(suiteName) + } + + self.defaults = defaults + return defaults + } + + private func storageKey(kind: String) -> String { + return "dev.expo.appintents.entities.\(kind)" + } + + private func isBlank(_ value: String) -> Bool { + return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Returns the stored catalog of the given kind, throwing when the stored blob cannot be decoded. + /// Nothing is set aside the way `AppIntentInvocationStore.pending()` sets a corrupt queue aside. A + /// catalog is owned by JavaScript rather than accumulated natively, so the next `setCatalog` for the + /// kind replaces the unreadable blob and nothing is lost by leaving it in place until then. + public func entities(ofKind kind: String) throws -> [AppIntentEntityRecord] { + let defaults = try requireDefaults() + guard let data = defaults.data(forKey: storageKey(kind: kind)) else { + return [] + } + + do { + return try JSONDecoder().decode([AppIntentEntityRecord].self, from: data) + } catch { + let message = """ + expo-app-intents could not read the '\(kind)' entity catalog, so Siri and Shortcuts have no \ + values to offer or resolve for it. The stored data is not valid JSON for this version of \ + the module, which usually means a different version wrote it. Call \ + setEntityCatalogAsync('\(kind)', ...) to replace it. Decoding error: \(error.localizedDescription) + """ + log.error(message) + throw AppIntentEntityCatalogDecodingException(message) + } + } + + public func entities( + ofKind kind: String, + matching identifiers: [String] + ) throws -> [AppIntentEntityRecord] { + let identifierSet = Set(identifiers) + return try entities(ofKind: kind).filter { identifierSet.contains($0.id) } + } + + /// Replaces the catalog of the given kind, throwing instead of leaving the previous one in place + /// without saying so. + internal func setCatalog(kind: String, entities: [AppIntentEntityRecord]) throws { + // The kind names the catalog that app-target `EntityQuery` implementations read, so a blank + // kind stores a catalog no query ever asks for. + if isBlank(kind) { + throw AppIntentEntityCatalogKindException( + """ + expo-app-intents rejected an entity catalog because its kind is empty. Call \ + setEntityCatalogAsync with the same non-empty kind your EntityQuery uses. + """ + ) + } + + // An entity without an identifier can never be resolved or matched, and an entity without a + // title has nothing for Siri to match speech against. Required `@Record` properties reject a + // missing key, but not one explicitly set to an empty (or whitespace-only) string. + if let invalid = entities.first(where: { isBlank($0.id) || isBlank($0.title) }) { + throw AppIntentEntityInvalidFieldException( + """ + expo-app-intents rejected the '\(kind)' entity catalog because an entity has an empty \ + \(isBlank(invalid.id) ? "id" : "title"). Give every entity a non-empty 'id' and 'title', \ + then call 'setEntityCatalogAsync' again. + """ + ) + } + + // Two entities with one id cannot both be resolved, so the id no longer names one entity. + var seenIds = Set() + for entity in entities where !seenIds.insert(entity.id).inserted { + throw AppIntentEntityDuplicateIdException( + """ + expo-app-intents rejected the '\(kind)' entity catalog because more than one entity has \ + the id '\(entity.id)'. Give every entity a unique 'id', then call setEntityCatalogAsync again. + """ + ) + } + + let defaults = try requireDefaults() + do { + let data = try JSONEncoder().encode(entities) + defaults.set(data, forKey: storageKey(kind: kind)) + } catch { + // Every field of `AppIntentEntityRecord` is a string, so this should never happen. + throw AppIntentEntityCatalogEncodingException( + "expo-app-intents could not save the '\(kind)' entity catalog. Encoding error: \(error.localizedDescription)" + ) + } + } +} + +internal final class AppIntentEntityStoreUnavailableException: GenericException, @unchecked Sendable { + override var reason: String { + return """ + expo-app-intents could not access the '\(param)' UserDefaults suite, so entity catalogs cannot \ + be read or written. Try again later. + """ + } +} + +internal final class AppIntentEntityCatalogDecodingException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} + +internal final class AppIntentEntityCatalogKindException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} + +internal final class AppIntentEntityInvalidFieldException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} + +internal final class AppIntentEntityDuplicateIdException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} + +internal final class AppIntentEntityCatalogEncodingException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} diff --git a/packages/expo-app-intents/ios/AppIntentInvocation.swift b/packages/expo-app-intents/ios/AppIntentInvocation.swift new file mode 100644 index 00000000000000..d6369bd2b85b08 --- /dev/null +++ b/packages/expo-app-intents/ios/AppIntentInvocation.swift @@ -0,0 +1,34 @@ +import ExpoModulesCore +import Foundation + +/// A single recorded App Intent invocation, persisted until JS removes it +/// with `removePendingInvocationAsync`. Delivery to JS is at-least-once. +public struct AppIntentInvocation: Codable, Sendable { + public let id: String + public let name: String + /// Intent-specific values. The top-level invocation schema is fixed; put custom + /// data for each intent here. + public let params: AppIntentParams + public let createdAt: Double + + public init(name: String, params: AppIntentParams) { + self.id = UUID().uuidString + self.name = name + // Params are made JSON-representable here, so the same values reach the persisted queue and + // the live event, and so no invocation can be lost to an encoding failure later on. + self.params = params.mapValues { $0.jsonSafe() } + self.createdAt = Date().timeIntervalSince1970 * 1000 + } + + func toDict() -> [String: Any] { + // swift-format requires a trailing comma in multiline collection literals. + // swiftlint:disable trailing_comma + return [ + "id": id, + "name": name, + "params": params.mapValues(\.foundationValue), + "createdAt": createdAt, + ] + // swiftlint:enable trailing_comma + } +} diff --git a/packages/expo-app-intents/ios/AppIntentInvocationStore.swift b/packages/expo-app-intents/ios/AppIntentInvocationStore.swift new file mode 100644 index 00000000000000..14b2ce2e83bab1 --- /dev/null +++ b/packages/expo-app-intents/ios/AppIntentInvocationStore.swift @@ -0,0 +1,116 @@ +import ExpoModulesCore +import Foundation + +/// Thrown when the persisted invocation queue cannot be read or written. +internal final class AppIntentQueueException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} + +/// UserDefaults-backed persistence for intent invocations. +internal final class AppIntentInvocationStore { + /// The value has been chosen arbitrarily, but is comfortably above the number of invocations a user can trigger + /// while JavaScript is cold, which is what the queue exists to hold. + internal static let maxPendingInvocations = 100 + private static let pendingKey = "invocations.pending" + /// Where an undecodable queue is set aside, so a bug report can still recover the raw bytes. + private static let corruptedPendingKey = "invocations.pending.corrupted" + private let defaults: UserDefaults + /// A saturated queue drops one invocation on every append, so the log would repeat the same + /// message on every dispatch; once per launch is enough to point at the fix. + private var didLogDroppedInvocations = false + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + init?(userDefaultsSuiteName: String) { + guard let defaults = UserDefaults(suiteName: userDefaultsSuiteName) else { + return nil + } + self.defaults = defaults + } + + private func storageKey(_ key: String) -> String { + return "dev.expo.appintents.\(key)" + } + + /// Returns the persisted queue, throwing when the stored blob cannot be decoded. + func pending() throws -> [AppIntentInvocation] { + guard let data = defaults.data(forKey: storageKey(Self.pendingKey)) else { + return [] + } + + do { + return try JSONDecoder().decode([AppIntentInvocation].self, from: data) + } catch { + // Set the bytes aside before anything can overwrite them. + defaults.set(data, forKey: storageKey(Self.corruptedPendingKey)) + defaults.removeObject(forKey: storageKey(Self.pendingKey)) + + let message = + "expo-app-intents could not read the pending App Intent invocation queue, so any invocation " + + "that was waiting for JavaScript is not delivered. The stored data is not valid JSON for " + + "this version of the module, which usually means it was written by a different one. The " + + "queue starts empty from now on; the unreadable data is kept under " + + "'\(storageKey(Self.corruptedPendingKey))' in UserDefaults. Trigger the intent again, and " + + "please report this at https://github.com/expo/expo/issues with that data. Decoding " + + "error: \(error.localizedDescription)" + log.error(message) + throw AppIntentQueueException(message) + } + } + + /// Appends an invocation, keeping it even when the stored queue was unreadable. + /// + /// `dispatch` runs while JavaScript may still be cold, so there is nowhere to report a failure. + /// `pending()` has already set a corrupt queue aside and logged it by this point, so starting a + /// fresh queue is safe here. + func append(_ invocation: AppIntentInvocation) { + var invocations = (try? pending()) ?? [] + invocations.append(invocation) + + if invocations.count > Self.maxPendingInvocations { + let droppedCount = invocations.count - Self.maxPendingInvocations + invocations.removeFirst(droppedCount) + if !didLogDroppedInvocations { + didLogDroppedInvocations = true + log.error( + "expo-app-intents dropped \(droppedCount) pending App Intent invocation(s) to keep the " + + "queue at its limit of \(Self.maxPendingInvocations), and keeps dropping the oldest " + + "one on every dispatch until the queue is drained (this is only logged once). Mount " + + "useAppIntents() once near the root of your app, and remove each invocation once you " + + "have handled it. The newest \(Self.maxPendingInvocations) invocations are kept." + ) + } + } + + do { + try persist(invocations) + } catch { + log.error( + "expo-app-intents could not save the pending App Intent invocation queue, so the invocation " + + "that just arrived is lost and never reaches JavaScript. Encoding error: \(error.localizedDescription)" + ) + } + } + + func remove(id: String) throws { + try persist(try pending().filter { $0.id != id }) + } + + /// Drops the whole queue, including a blob that was set aside as corrupt. + func clear() { + defaults.removeObject(forKey: storageKey(Self.pendingKey)) + defaults.removeObject(forKey: storageKey(Self.corruptedPendingKey)) + } + + /// Writes the queue, leaving the stored queue untouched when it cannot be encoded. + /// `AppIntentInvocation` makes its params JSON-representable, so this should never fail; if it + /// ever does, keeping the previous queue is better than replacing it with nothing. + private func persist(_ invocations: [AppIntentInvocation]) throws { + let data = try JSONEncoder().encode(invocations) + defaults.set(data, forKey: storageKey(Self.pendingKey)) + } +} diff --git a/packages/expo-app-intents/ios/AppIntentValue.swift b/packages/expo-app-intents/ios/AppIntentValue.swift new file mode 100644 index 00000000000000..8ba2f61e635b9a --- /dev/null +++ b/packages/expo-app-intents/ios/AppIntentValue.swift @@ -0,0 +1,147 @@ +import ExpoModulesCore +import Foundation + +public typealias AppIntentParams = [String: AppIntentValue] + +// swift-format moves the brace to a new line for wrapped declarations. +// swiftlint:disable opening_brace +/// A Codable, Sendable JSON value used to persist App Intent params while JS is cold. +public enum AppIntentValue: Codable, Equatable, Sendable, ExpressibleByStringLiteral, + ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral, ExpressibleByBooleanLiteral, + ExpressibleByArrayLiteral, ExpressibleByDictionaryLiteral, ExpressibleByNilLiteral +{ + // swiftlint:enable opening_brace + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([AppIntentValue]) + case object([String: AppIntentValue]) + case null + + public init(_ value: String) { + self = .string(value) + } + + public init(_ value: Int) { + self = .int(value) + } + + public init(_ value: Double) { + self = .double(value) + } + + public init(_ value: Bool) { + self = .bool(value) + } + + /// Returns an equivalent value that JSON can represent. + func jsonSafe() -> AppIntentValue { + switch self { + case .double(let value) where !value.isFinite: + log.warn( + "expo-app-intents replaced a non-finite number (\(value)) in App Intent params with null, " + + "because JSON cannot represent NaN or infinity and the invocation would otherwise be " + + "dropped. Pass a finite number, or a string if the exact value matters." + ) + return .null + case .array(let values): + return .array(values.map { $0.jsonSafe() }) + case .object(let values): + return .object(values.mapValues { $0.jsonSafe() }) + default: + return self + } + } + + var foundationValue: Any { + switch self { + case .string(let value): + return value + case .int(let value): + return value + case .double(let value): + return value + case .bool(let value): + return value + case .array(let value): + return value.map(\.foundationValue) + case .object(let value): + return value.mapValues(\.foundationValue) + case .null: + return NSNull() + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([AppIntentValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: AppIntentValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported App Intent payload value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + + public init(stringLiteral value: String) { + self = .string(value) + } + + public init(integerLiteral value: Int) { + self = .int(value) + } + + public init(floatLiteral value: Double) { + self = .double(value) + } + + public init(booleanLiteral value: Bool) { + self = .bool(value) + } + + public init(arrayLiteral elements: AppIntentValue...) { + self = .array(elements) + } + + public init(dictionaryLiteral elements: (String, AppIntentValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } + + public init(nilLiteral: ()) { + self = .null + } +} diff --git a/packages/expo-app-intents/ios/ExpoAppIntents.podspec b/packages/expo-app-intents/ios/ExpoAppIntents.podspec new file mode 100644 index 00000000000000..bec74bbcce0021 --- /dev/null +++ b/packages/expo-app-intents/ios/ExpoAppIntents.podspec @@ -0,0 +1,35 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'ExpoAppIntents' + s.version = package['version'] + s.summary = package['description'] + s.description = package['description'] + s.license = package['license'] + s.author = package['author'] + s.homepage = package['homepage'] + s.platforms = { + :ios => '16.4', + :osx => '13.4', + :tvos => '16.4' + } + s.swift_version = '6.0' + s.source = { git: 'https://github.com/expo/expo.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.source_files = "**/*.{h,m,swift}" + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.exclude_files = 'Tests/' + s.test_spec 'Tests' do |test_spec| + test_spec.dependency 'ExpoModulesTestCore' + test_spec.source_files = 'Tests/**/*.{m,swift}' + end +end diff --git a/packages/expo-app-intents/ios/ExpoAppIntentsModule.swift b/packages/expo-app-intents/ios/ExpoAppIntentsModule.swift new file mode 100644 index 00000000000000..da04e20305b62b --- /dev/null +++ b/packages/expo-app-intents/ios/ExpoAppIntentsModule.swift @@ -0,0 +1,85 @@ +import ExpoModulesCore + +internal final class ShortcutsRefreshUnavailableException: Exception, @unchecked Sendable { + override var reason: String { + "App Shortcuts could not be refreshed because no refresh handler is registered. " + + "The app target must contain an 'AppIntentsSetup' inline module that sets " + + "AppIntentDispatcher.shared.setShortcutsRefreshHandler(...). Run `npx expo-app-intents init` " + + "to generate it, or add it manually as described in the expo-app-intents documentation." + } +} + +public final class ExpoAppIntentsModule: Module, @unchecked Sendable { + private var invocationEventsTask: Task? + private var invocationEventsSubscription: AppIntentEventSubscription? + + public func definition() -> ModuleDefinition { + Name("ExpoAppIntents") + + Events("onIntent") + + OnCreate { [weak self] in + guard let self else { + return + } + // The token is created here, synchronously, so `OnDestroy` can invalidate it before the task + // below ever reaches the dispatcher. + let subscription = AppIntentEventSubscription() + self.invocationEventsSubscription = subscription + self.invocationEventsTask = Task { [weak self] in + for await invocation in await AppIntentDispatcher.shared.invocationEvents(for: subscription) { + guard !Task.isCancelled else { + break + } + self?.sendIntentEvent(invocation) + } + } + } + + OnDestroy { + invocationEventsSubscription?.invalidate() + invocationEventsSubscription = nil + invocationEventsTask?.cancel() + invocationEventsTask = nil + } + + AsyncFunction("getPendingInvocationsAsync") { () async throws -> [[String: Any]] in + return try await AppIntentDispatcher.shared.pendingInvocations().map { $0.toDict() } + } + + AsyncFunction("removePendingInvocationAsync") { (id: String) async throws in + try await AppIntentDispatcher.shared.removePendingInvocation(id: id) + } + + AsyncFunction("clearPendingInvocationsAsync") { () async in + await AppIntentDispatcher.shared.clearPendingInvocations() + } + + AsyncFunction("setEntityCatalogAsync") { (kind: String, entities: [AppIntentEntityRecord]) async throws in + try await AppIntentEntityStore.shared.setCatalog(kind: kind, entities: entities) + // Best-effort, and deliberately not propagated: the catalog is already stored by this point, + // so failing the call would report a write that did happen as an error. An app with no App + // Shortcut phrases has no `AppShortcutsProvider` to register a refresh handler, and no + // shortcut parameters to re-train either. + await AppIntentDispatcher.shared.requestShortcutsRefresh() + } + + AsyncFunction("getEntityCatalogAsync") { (kind: String) async throws -> [AppIntentEntityRecord] in + return try await AppIntentEntityStore.shared.entities(ofKind: kind) + } + + AsyncFunction("refreshShortcutsAsync") { () async throws in + try await self.refreshShortcuts() + } + } + + private func refreshShortcuts() async throws { + if !(await AppIntentDispatcher.shared.requestShortcutsRefresh()) { + throw ShortcutsRefreshUnavailableException() + } + } + + private func sendIntentEvent(_ invocation: AppIntentInvocation) { + sendEvent("onIntent", invocation.toDict()) + } +} diff --git a/packages/expo-app-intents/ios/Tests/AppIntentDispatcherTests.swift b/packages/expo-app-intents/ios/Tests/AppIntentDispatcherTests.swift new file mode 100644 index 00000000000000..9e01fc5fae9129 --- /dev/null +++ b/packages/expo-app-intents/ios/Tests/AppIntentDispatcherTests.swift @@ -0,0 +1,232 @@ +import Foundation +import Testing + +@testable import ExpoAppIntents + +/// Collects what a stream consumer saw, so assertions never depend on task scheduling order. +private actor EventRecorder { + private(set) var names: [String] = [] + private(set) var didFinish = false + + func record(_ name: String) { + names.append(name) + } + + func markFinished() { + didFinish = true + } +} + +/// Serialized because every test works in the same `#file`-named UserDefaults suite. +@Suite("AppIntentDispatcher", .serialized) +struct AppIntentDispatcherTests { + private let dispatcher: AppIntentDispatcher + private let defaults: UserDefaults + + init() throws { + defaults = try #require(UserDefaults(suiteName: #file)) + defaults.removePersistentDomain(forName: #file) + dispatcher = AppIntentDispatcher( + store: try #require(AppIntentInvocationStore(userDefaultsSuiteName: #file)) + ) + } + + @Test + func `dispatch persists before any listener`() async throws { + let id = await dispatcher.dispatch(name: "increaseCounter", params: ["by": 1]) + + let pending = try await dispatcher.pendingInvocations() + #expect(pending.map(\.id) == [id]) + #expect(pending[0].name == "increaseCounter") + } + + @Test + func `dispatch yields an invocation event`() async throws { + let recorder = EventRecorder() + let consumer = await subscribe(into: recorder) + + await dispatcher.dispatch(name: "startHike", params: ["trailId": "t1"]) + + let delivered = await waitUntil { await recorder.names == ["startHike"] } + #expect(delivered, "expected the live listener to receive 'startHike'") + let pending = try await dispatcher.pendingInvocations() + #expect(pending.count == 1) + consumer.cancel() + } + + @Test + func `terminating an old event stream does not clear a newer listener`() async { + let oldRecorder = EventRecorder() + let oldConsumer = await subscribe(into: oldRecorder) + + let newRecorder = EventRecorder() + let newConsumer = await subscribe(into: newRecorder) + + oldConsumer.cancel() + _ = await waitUntil { await oldRecorder.didFinish } + + await dispatcher.dispatch(name: "afterRefresh", params: [:]) + + let delivered = await waitUntil { await newRecorder.names == ["afterRefresh"] } + #expect(delivered, "expected the newer listener to receive 'afterRefresh'") + newConsumer.cancel() + } + + /// Two `AppContext`s genuinely coexist, for example while a JavaScript reload settles. A second + /// subscription must not silence the first: with a single continuation the older context was deaf + /// for the rest of its life. + @Test + func `every subscriber receives every invocation`() async { + let firstRecorder = EventRecorder() + let firstConsumer = await subscribe(into: firstRecorder) + let secondRecorder = EventRecorder() + let secondConsumer = await subscribe(into: secondRecorder) + + await dispatcher.dispatch(name: "startHike", params: [:]) + + let firstGotIt = await waitUntil { await firstRecorder.names == ["startHike"] } + let secondGotIt = await waitUntil { await secondRecorder.names == ["startHike"] } + #expect(firstGotIt, "expected the older subscriber to keep receiving") + #expect(secondGotIt, "expected the newer subscriber to receive") + let firstFinished = await firstRecorder.didFinish + #expect(!firstFinished, "a second subscription must not finish the first stream") + + firstConsumer.cancel() + secondConsumer.cancel() + } + + /// Destroying the newest subscriber must not leave the app deaf. With a single continuation, the + /// remaining context had no way to register again and nothing listened to `onIntent` any more. + @Test + func `the remaining subscriber still receives after the newest is destroyed`() async { + let survivingRecorder = EventRecorder() + let survivingConsumer = await subscribe(into: survivingRecorder) + + let doomedRecorder = EventRecorder() + let doomedConsumer = await subscribe(into: doomedRecorder) + doomedConsumer.cancel() + _ = await waitUntil { await doomedRecorder.didFinish } + + await dispatcher.dispatch(name: "afterTeardown", params: [:]) + + let delivered = await waitUntil { await survivingRecorder.names == ["afterTeardown"] } + #expect(delivered, "expected the surviving subscriber to still receive invocations") + survivingConsumer.cancel() + } + + /// A JavaScript reload briefly keeps two modules alive. The old module's `OnDestroy` cancels its + /// event task, but a cancelled task still runs its body, so it can still reach the dispatcher after + /// the new module registered its stream. Registering then would immediately terminate again and + /// leave nothing listening, and `onIntent` would never fire again. + @Test + func `a cancelled task cannot replace the live listener`() async { + let liveRecorder = EventRecorder() + let liveConsumer = await subscribe(into: liveRecorder) + + let staleTask = Task { [dispatcher] in + let subscription = AppIntentEventSubscription() + for await _ in await dispatcher.invocationEvents(for: subscription) {} + } + staleTask.cancel() + _ = await waitUntil { staleTask.isCancelled } + try? await Task.sleep(nanoseconds: 100_000_000) + + await dispatcher.dispatch(name: "afterReload", params: [:]) + + let delivered = await waitUntil { await liveRecorder.names == ["afterReload"] } + #expect(delivered, "expected the live listener to keep receiving after a stale task ran") + liveConsumer.cancel() + } + + /// Same race, resolved through the token the destroyed module invalidated in `OnDestroy`. + @Test + func `an invalidated subscription cannot replace the live listener`() async { + let liveRecorder = EventRecorder() + let liveConsumer = await subscribe(into: liveRecorder) + + let staleSubscription = AppIntentEventSubscription() + staleSubscription.invalidate() + let staleRecorder = EventRecorder() + let staleConsumer = consume( + await dispatcher.invocationEvents(for: staleSubscription), + into: staleRecorder + ) + _ = await waitUntil { await staleRecorder.didFinish } + + await dispatcher.dispatch(name: "afterReload", params: [:]) + + let delivered = await waitUntil { await liveRecorder.names == ["afterReload"] } + let staleNames = await staleRecorder.names + #expect(delivered, "expected the live listener to keep receiving after a stale token ran") + #expect(staleNames.isEmpty) + liveConsumer.cancel() + staleConsumer.cancel() + } + + @Test + func `removes a pending invocation and clears the queue`() async throws { + let id = await dispatcher.dispatch(name: "a", params: [:]) + await dispatcher.dispatch(name: "b", params: [:]) + + try await dispatcher.removePendingInvocation(id: id) + let afterRemove = try await dispatcher.pendingInvocations() + #expect(afterRemove.count == 1) + + await dispatcher.clearPendingInvocations() + let afterClear = try await dispatcher.pendingInvocations() + #expect(afterClear.isEmpty) + } + + @Test + func `requestShortcutsRefresh invokes the registered handler`() async { + let handlerCalled = EventRecorder() + await dispatcher.setShortcutsRefreshHandler { + await handlerCalled.record("refresh") + } + + let refreshed = await dispatcher.requestShortcutsRefresh() + let names = await handlerCalled.names + #expect(refreshed) + #expect(names == ["refresh"]) + } + + @Test + func `requestShortcutsRefresh without a handler returns false`() async { + let refreshed = await dispatcher.requestShortcutsRefresh() + #expect(!refreshed) + } + + /// Subscribes with a token of its own, because `invocationEvents(for:)` has no default one. + private func subscribe(into recorder: EventRecorder) async -> Task { + let stream = await dispatcher.invocationEvents(for: AppIntentEventSubscription()) + return consume(stream, into: recorder) + } + + private func consume( + _ stream: AsyncStream, + into recorder: EventRecorder + ) -> Task { + return Task { + for await invocation in stream { + await recorder.record(invocation.name) + } + await recorder.markFinished() + } + } + + /// Polls until `condition` holds. Awaiting a task that never completes would hang the whole test + /// run, so every wait here is bounded and reported as a failed assertion instead. + private func waitUntil( + timeout: UInt64 = 2_000_000_000, + _ condition: @Sendable () async -> Bool + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeout + while DispatchTime.now().uptimeNanoseconds < deadline { + if await condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return await condition() + } +} diff --git a/packages/expo-app-intents/ios/Tests/AppIntentEntityStoreTests.swift b/packages/expo-app-intents/ios/Tests/AppIntentEntityStoreTests.swift new file mode 100644 index 00000000000000..c47f806eb21259 --- /dev/null +++ b/packages/expo-app-intents/ios/Tests/AppIntentEntityStoreTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing + +@testable import ExpoAppIntents +@testable import ExpoModulesCore + +/// Serialized because every test works in the same `#file`-named UserDefaults suite. +@Suite("AppIntentEntityStore", .serialized) +struct AppIntentEntityStoreTests { + private static let trailStorageKey = "dev.expo.appintents.entities.trail" + + private let store: AppIntentEntityStore + private let defaults: UserDefaults + + init() throws { + defaults = try #require(UserDefaults(suiteName: #file)) + defaults.removePersistentDomain(forName: #file) + store = AppIntentEntityStore(userDefaultsSuiteName: #file) + } + + @Test + func `sets and reads a catalog`() async throws { + try await store.setCatalog( + kind: "trail", + entities: [ + AppIntentEntityRecord(id: "t1", title: "Eagle Peak", subtitle: "5 km", synonyms: ["eagle"]), + AppIntentEntityRecord(id: "t2", title: "Lake Loop", subtitle: nil, synonyms: []), + ] + ) + + let all = try await store.entities(ofKind: "trail") + #expect(all.map(\.id) == ["t1", "t2"]) + #expect(all[0].title == "Eagle Peak") + #expect(all[0].subtitle == "5 km") + #expect(all[0].synonyms == ["eagle"]) + #expect(all[1].subtitle == nil) + } + + @Test + func `filters entities by matching identifiers`() async throws { + try await store.setCatalog( + kind: "trail", + entities: [ + AppIntentEntityRecord(id: "t1", title: "A", subtitle: nil, synonyms: []), + AppIntentEntityRecord(id: "t2", title: "B", subtitle: nil, synonyms: []), + ] + ) + + let matching = try await store.entities(ofKind: "trail", matching: ["t2"]) + #expect(matching.map(\.id) == ["t2"]) + } + + @Test + func `an unknown kind is empty`() async throws { + let entities = try await store.entities(ofKind: "missing") + #expect(entities.isEmpty) + } + + /// A `try?` here used to hand back an empty catalog, which is exactly what an unpublished kind looks + /// like: Siri would offer nothing and the developer would never learn why. + @Test + func `entities reports an undecodable catalog instead of returning it empty`() async { + defaults.set(Data("not json at all".utf8), forKey: Self.trailStorageKey) + + await #expect(throws: (any Error).self) { + _ = try await store.entities(ofKind: "trail") + } + } + + /// Publishing the kind again has to recover, because the catalog is owned by JavaScript. + @Test + func `setCatalog replaces an undecodable catalog`() async throws { + defaults.set(Data("not json at all".utf8), forKey: Self.trailStorageKey) + + try await store.setCatalog( + kind: "trail", + entities: [AppIntentEntityRecord(id: "t1", title: "Eagle Peak")] + ) + + let readBack = try await store.entities(ofKind: "trail") + #expect(readBack.map(\.id) == ["t1"]) + } + + /// A non-optional `@Record` property without a default is required at the JavaScript boundary. + @Test + func `record requires an id and a title`() throws { + let appContext = AppContext.create() + + #expect(throws: RecordPropertyRequiredException.self) { + try AppIntentEntityRecord.from(dictionary: [:], appContext: appContext) + } + #expect(throws: RecordPropertyRequiredException.self) { + try AppIntentEntityRecord.from(dictionary: ["id": "t1"], appContext: appContext) + } + #expect(throws: RecordPropertyRequiredException.self) { + try AppIntentEntityRecord.from(dictionary: ["title": "A"], appContext: appContext) + } + _ = try AppIntentEntityRecord.from( + dictionary: ["id": "t1", "title": "A"], + appContext: appContext + ) + } + + /// Required record fields reject missing keys, but an explicit empty string is just as + /// unresolvable, and so is a string made only of whitespace. + @Test + func `setCatalog rejects an empty or blank identifier or title`() async throws { + for invalid in [ + AppIntentEntityRecord(id: "", title: "No id"), + AppIntentEntityRecord(id: " ", title: "Blank id"), + AppIntentEntityRecord(id: "t1", title: ""), + AppIntentEntityRecord(id: "t1", title: "\n"), + ] { + await #expect(throws: AppIntentEntityInvalidFieldException.self) { + try await store.setCatalog(kind: "trail", entities: [invalid]) + } + } + + let stored = try await store.entities(ofKind: "trail") + #expect(stored.isEmpty, "a rejected catalog must not be stored") + } + + /// The kind names the catalog that app-target `EntityQuery` implementations read, so a blank kind + /// stores a catalog no query ever finds. Entities get this validation; the kind needs it too. + @Test + func `setCatalog rejects a blank kind`() async throws { + for blankKind in ["", " ", "\n"] { + await #expect( + throws: AppIntentEntityCatalogKindException.self, + "expected setCatalog to reject the blank kind '\(blankKind)'" + ) { + try await store.setCatalog( + kind: blankKind, + entities: [AppIntentEntityRecord(id: "t1", title: "A")] + ) + } + } + } + + /// Two entities with one id cannot both be resolved, so the catalog is ambiguous as a whole. + @Test + func `setCatalog rejects duplicate identifiers`() async throws { + await #expect(throws: AppIntentEntityDuplicateIdException.self) { + try await store.setCatalog( + kind: "trail", + entities: [ + AppIntentEntityRecord(id: "t1", title: "Eagle Peak"), + AppIntentEntityRecord(id: "t1", title: "Lake Loop"), + ] + ) + } + + let stored = try await store.entities(ofKind: "trail") + #expect(stored.isEmpty, "a rejected catalog must not be stored") + } +} diff --git a/packages/expo-app-intents/ios/Tests/AppIntentInvocationStoreTests.swift b/packages/expo-app-intents/ios/Tests/AppIntentInvocationStoreTests.swift new file mode 100644 index 00000000000000..72f451b0806da2 --- /dev/null +++ b/packages/expo-app-intents/ios/Tests/AppIntentInvocationStoreTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing + +@testable import ExpoAppIntents + +/// Serialized because every test works in the same `#file`-named UserDefaults suite. +@Suite("AppIntentInvocationStore", .serialized) +struct AppIntentInvocationStoreTests { + private static let pendingStorageKey = "dev.expo.appintents.invocations.pending" + private static let corruptedStorageKey = "dev.expo.appintents.invocations.pending.corrupted" + + private let store: AppIntentInvocationStore + private let defaults: UserDefaults + + init() throws { + defaults = try #require(UserDefaults(suiteName: #file)) + defaults.removePersistentDomain(forName: #file) + store = AppIntentInvocationStore(defaults: defaults) + } + + @Test + func `appends and reads pending invocations`() throws { + let invocation = AppIntentInvocation(name: "startHike", params: ["trailId": "t1"]) + store.append(invocation) + + let pending = try store.pending() + #expect(pending.count == 1) + #expect(pending[0].id == invocation.id) + #expect(pending[0].name == "startHike") + #expect(pending[0].params["trailId"] == .string("t1")) + } + + @Test + func `removes an invocation by id`() throws { + let first = AppIntentInvocation(name: "a", params: [:]) + let second = AppIntentInvocation(name: "b", params: [:]) + store.append(first) + store.append(second) + + try store.remove(id: first.id) + + #expect(try store.pending().map(\.id) == [second.id]) + } + + @Test + func `clears the queue`() throws { + store.append(AppIntentInvocation(name: "a", params: [:])) + store.clear() + #expect(try store.pending().isEmpty) + } + + @Test + func `append keeps the queue when params cannot be represented in JSON`() throws { + store.append(AppIntentInvocation(name: "queued", params: ["ok": "yes"])) + store.append(AppIntentInvocation(name: "nonFinite", params: ["x": .double(Double.nan)])) + + let pending = try store.pending() + #expect(pending.map(\.name) == ["queued", "nonFinite"]) + #expect(pending.last?.params["x"] == .null) + } + + @Test + func `persists across instances`() throws { + store.append(AppIntentInvocation(name: "cold", params: [:])) + let secondInstance = AppIntentInvocationStore(defaults: defaults) + #expect(try secondInstance.pending().first?.name == "cold") + } + + /// A `try?` here used to hand back an empty queue, which the next write turned into the real stored + /// queue. The corruption has to be reported instead, and the bytes have to survive it. + @Test + func `pending reports a corrupt queue instead of returning it empty`() { + let corrupt = Data("not json at all".utf8) + defaults.set(corrupt, forKey: Self.pendingStorageKey) + + #expect(throws: (any Error).self) { + try store.pending() + } + #expect(defaults.data(forKey: Self.corruptedStorageKey) == corrupt) + } + + /// The next dispatch must still be recorded, and must not be what destroys the corrupt blob. + @Test + func `append after corruption keeps both the new invocation and the corrupt bytes`() throws { + let corrupt = Data("not json at all".utf8) + defaults.set(corrupt, forKey: Self.pendingStorageKey) + + store.append(AppIntentInvocation(name: "afterCorruption", params: [:])) + + #expect(try store.pending().map(\.name) == ["afterCorruption"]) + #expect(defaults.data(forKey: Self.corruptedStorageKey) == corrupt) + } + + /// Reporting once is enough: the queue recovers rather than failing on every later read. + @Test + func `pending recovers after reporting corruption`() throws { + defaults.set(Data("not json at all".utf8), forKey: Self.pendingStorageKey) + + #expect(throws: (any Error).self) { + try store.pending() + } + #expect(try store.pending().isEmpty) + } + + @Test + func `clear also drops the corrupt blob`() { + defaults.set(Data("not json at all".utf8), forKey: Self.pendingStorageKey) + #expect(throws: (any Error).self) { + try store.pending() + } + + store.clear() + + #expect(defaults.data(forKey: Self.corruptedStorageKey) == nil) + } + + @Test + func `remove reports a corrupt queue`() { + defaults.set(Data("not json at all".utf8), forKey: Self.pendingStorageKey) + + #expect(throws: (any Error).self) { + try store.remove(id: "whatever") + } + } + + /// Only JavaScript drains this queue, so an app that never dequeues - one whose handler throws, or + /// that never mounts `useAppIntents` - would grow it without bound. `UserDefaults` is read into + /// memory when the app launches, so that is a cost every later start pays. + @Test + func `append drops the oldest invocations once the queue is full`() throws { + let capacity = AppIntentInvocationStore.maxPendingInvocations + for index in 0...capacity { + store.append(AppIntentInvocation(name: "invocation\(index)", params: [:])) + } + + let pending = try store.pending() + #expect(pending.count == capacity, "the queue may not grow past its capacity") + // The newest invocation is the one the user just asked for, so the oldest is the one to lose. + #expect(pending.first?.name == "invocation1", "the oldest invocation is the one dropped") + #expect(pending.last?.name == "invocation\(capacity)", "the newest invocation is kept") + } + + /// A queue already over capacity - written by a build with a higher cap - has to come back down. + @Test + func `append trims a queue that is already over capacity`() throws { + let capacity = AppIntentInvocationStore.maxPendingInvocations + let oversized = (0..<(capacity + 10)).map { AppIntentInvocation(name: "old\($0)", params: [:]) } + defaults.set(try JSONEncoder().encode(oversized), forKey: Self.pendingStorageKey) + + store.append(AppIntentInvocation(name: "newest", params: [:])) + + let pending = try store.pending() + #expect(pending.count == capacity) + #expect(pending.last?.name == "newest") + } +} diff --git a/packages/expo-app-intents/ios/Tests/AppIntentValueTests.swift b/packages/expo-app-intents/ios/Tests/AppIntentValueTests.swift new file mode 100644 index 00000000000000..e1f14d3de316bc --- /dev/null +++ b/packages/expo-app-intents/ios/Tests/AppIntentValueTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing + +@testable import ExpoAppIntents + +@Suite("AppIntentValue") +struct AppIntentValueTests { + @Test + func `round-trips every case through Codable`() throws { + let value = AppIntentValue.object([ + "string": .string("s"), + "int": .int(3), + "double": .double(1.5), + "bool": .bool(true), + "null": .null, + "array": .array([.int(1), .string("two"), .bool(false)]), + ]) + + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(AppIntentValue.self, from: data) + + #expect(decoded == value) + } + + @Test + func `jsonSafe replaces non-finite numbers with null`() { + #expect(AppIntentValue.double(Double.nan).jsonSafe() == .null) + #expect(AppIntentValue.double(Double.infinity).jsonSafe() == .null) + #expect(AppIntentValue.double(-Double.infinity).jsonSafe() == .null) + #expect(AppIntentValue.double(1.5).jsonSafe() == .double(1.5)) + } + + @Test + func `jsonSafe recurses into arrays and objects`() { + let value = AppIntentValue.object([ + "list": .array([.double(Double.infinity), .int(1)]), + "map": .object(["deep": .double(Double.nan)]), + ]) + + #expect( + value.jsonSafe() + == .object([ + "list": .array([.null, .int(1)]), + "map": .object(["deep": .null]), + ]) + ) + } + + @Test + func `invocation params are JSON-safe and match the live event payload`() throws { + let invocation = AppIntentInvocation(name: "nonFinite", params: ["x": .double(Double.nan)]) + + #expect(invocation.params["x"] == .null) + // The live event and the persisted invocation must carry the same value. + #expect((invocation.toDict()["params"] as? [String: Any])?["x"] is NSNull) + _ = try JSONEncoder().encode(invocation) + } +} diff --git a/packages/expo-app-intents/ios/Tests/ExpoAppIntentsModuleTests.swift b/packages/expo-app-intents/ios/Tests/ExpoAppIntentsModuleTests.swift new file mode 100644 index 00000000000000..a33f753059e67a --- /dev/null +++ b/packages/expo-app-intents/ios/Tests/ExpoAppIntentsModuleTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing + +@testable import ExpoAppIntents +@testable import ExpoModulesCore + +/// Covers the module's JavaScript-facing surface. These go through the runtime rather than calling the +/// actors directly, because what matters here is whether the JavaScript promise resolves or rejects. +@Suite("ExpoAppIntentsModule", .serialized) +@JavaScriptActor +struct ExpoAppIntentsModuleTests { + let appContext: AppContext + let runtime: ExpoRuntime + + init() throws { + appContext = AppContext.create() + runtime = try appContext.runtime + appContext.moduleRegistry.register( + holder: ModuleHolder( + appContext: appContext, + module: ExpoAppIntentsModule(appContext: appContext), + name: "ExpoAppIntents" + ) + ) + } + + /// A scaffold with no App Shortcut phrases has no `AppShortcutsProvider`, so nothing registers a + /// refresh handler. Publishing a catalog still has to succeed: there are no shortcut parameters to + /// re-train, and the catalog is already stored by the time the refresh is attempted. + @Test + func `publishing a catalog succeeds when no refresh handler is registered`() async throws { + let kind = "testDraftWithoutRefreshHandler" + let storageKey = "dev.expo.appintents.entities.\(kind)" + let defaults = try #require( + UserDefaults(suiteName: AppIntentEntityStore.userDefaultsSuiteName) + ) + // Cleared so the assertion below can only pass on what this test itself published, and cleared + // again on the way out so the published catalog does not outlive the test in the module's + // UserDefaults suite. + defaults.removeObject(forKey: storageKey) + defer { + defaults.removeObject(forKey: storageKey) + } + await AppIntentDispatcher.shared.setShortcutsRefreshHandler(nil) + + _ = try await runtime.evalAsync( + "expo.modules.ExpoAppIntents.setEntityCatalogAsync('\(kind)', [{ id: 'a', title: 'A' }])" + ) + + let stored = try await AppIntentEntityStore.shared.entities(ofKind: kind) + #expect(stored.map(\.id) == ["a"]) + } + + /// Asking for a refresh explicitly is different: there is nothing else the call could have + /// accomplished, so a missing handler has to surface rather than pass silently. + @Test + func `refreshing shortcuts fails when no refresh handler is registered`() async throws { + await AppIntentDispatcher.shared.setShortcutsRefreshHandler(nil) + + await #expect(throws: (any Error).self) { + _ = try await runtime.evalAsync("expo.modules.ExpoAppIntents.refreshShortcutsAsync()") + } + } +} diff --git a/packages/expo-app-intents/jest.config.js b/packages/expo-app-intents/jest.config.js new file mode 100644 index 00000000000000..3ff35e3ba996a3 --- /dev/null +++ b/packages/expo-app-intents/jest.config.js @@ -0,0 +1,4 @@ +// The config plugin has its own tests under `plugin/`, and the module preset roots collection at +// `src` — so without this they are collected by nothing and `passWithNoTests` keeps the run green. +// Running `plugin` as its own project means one `jest` invocation covers the whole package. +module.exports = require('expo-module-scripts/createCompositeJestPreset')(__dirname, ['plugin']); diff --git a/packages/expo-app-intents/oxlint.config.mjs b/packages/expo-app-intents/oxlint.config.mjs new file mode 100644 index 00000000000000..4a0fea02e3a3db --- /dev/null +++ b/packages/expo-app-intents/oxlint.config.mjs @@ -0,0 +1,2 @@ +// @generated by expo-module-scripts +export { default } from 'expo-module-scripts/oxlint.config.base'; diff --git a/packages/expo-app-intents/package.json b/packages/expo-app-intents/package.json new file mode 100644 index 00000000000000..3b193e72640777 --- /dev/null +++ b/packages/expo-app-intents/package.json @@ -0,0 +1,51 @@ +{ + "name": "expo-app-intents", + "version": "0.1.0", + "description": "Expose Apple App Intents (Siri, Shortcuts, Spotlight, Apple Intelligence) from Expo apps", + "main": "build/index.js", + "types": "build/index.d.ts", + "sideEffects": false, + "scripts": { + "build": "pnpm run build:lib && pnpm run build:plugin", + "build:lib": "expo-build src", + "build:plugin": "expo-build --base plugin cjs:src", + "clean": "expo-module clean", + "lint": "oxlint --config oxlint.config.mjs .", + "test": "jest", + "prepublishOnly": "expo-module prepublishOnly", + "depscheck": "expo-module depscheck", + "typecheck": "tsc -b tsconfig.all.json" + }, + "keywords": [ + "react-native", + "expo", + "expo-app-intents", + "siri", + "app-intents", + "shortcuts" + ], + "repository": { + "type": "git", + "url": "https://github.com/expo/expo.git", + "directory": "packages/expo-app-intents" + }, + "bugs": { + "url": "https://github.com/expo/expo/issues" + }, + "author": "650 Industries, Inc.", + "license": "MIT", + "homepage": "https://docs.expo.dev/versions/latest/sdk/app-intents", + "devDependencies": { + "@testing-library/react-native": "^13.3.0", + "@types/jest": "^29.2.1", + "@types/node": "^22.14.0", + "@types/react": "~19.2.0", + "expo": "workspace:*", + "expo-module-scripts": "workspace:*" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } +} diff --git a/packages/expo-app-intents/plugin/index.d.ts b/packages/expo-app-intents/plugin/index.d.ts new file mode 100644 index 00000000000000..99a73babced38a --- /dev/null +++ b/packages/expo-app-intents/plugin/index.d.ts @@ -0,0 +1 @@ +export { default } from './build'; diff --git a/packages/expo-app-intents/plugin/index.js b/packages/expo-app-intents/plugin/index.js new file mode 100644 index 00000000000000..a5c027389cd64c --- /dev/null +++ b/packages/expo-app-intents/plugin/index.js @@ -0,0 +1 @@ +module.exports = require('./build'); diff --git a/packages/expo-app-intents/plugin/jest.config.js b/packages/expo-app-intents/plugin/jest.config.js new file mode 100644 index 00000000000000..e539b3b187ee4f --- /dev/null +++ b/packages/expo-app-intents/plugin/jest.config.js @@ -0,0 +1 @@ +module.exports = require('expo-module-scripts/jest-preset-plugin'); diff --git a/packages/expo-app-intents/plugin/src/__tests__/withAppIntents.test.ts b/packages/expo-app-intents/plugin/src/__tests__/withAppIntents.test.ts new file mode 100644 index 00000000000000..5a0d1e91592e04 --- /dev/null +++ b/packages/expo-app-intents/plugin/src/__tests__/withAppIntents.test.ts @@ -0,0 +1,111 @@ +import { WarningAggregator } from 'expo/config-plugins'; + +import withAppIntents, { withAppIntentsValidation } from '../withAppIntents'; + +jest.mock('expo/config-plugins', () => { + const plugins = jest.requireActual('expo/config-plugins'); + return { + ...plugins, + WarningAggregator: { addWarningIOS: jest.fn() }, + }; +}); + +const baseConfig = { name: 'test-app', slug: 'test-app' } as any; + +function configWatching(...watchedDirectories: string[]) { + return { + ...baseConfig, + experiments: { inlineModules: { watchedDirectories } }, + }; +} + +describe(withAppIntentsValidation, () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws an actionable error when inline modules are not configured', () => { + expect(() => withAppIntentsValidation(baseConfig, { directory: 'app-intents' })).toThrow( + /experiments\.inlineModules/ + ); + }); + + it('throws when the intents directory is not watched', () => { + const config = configWatching('other'); + expect(() => withAppIntentsValidation(config, { directory: 'app-intents' })).toThrow( + /app-intents/ + ); + }); + + it('throws when a watched directory only shares a prefix with the intents directory', () => { + const config = configWatching('app-intents-extra'); + expect(() => withAppIntentsValidation(config, { directory: 'app-intents' })).toThrow( + /app-intents/ + ); + }); + + it('names the plugin directory prop as the other way out', () => { + const config = configWatching('ios-modules'); + expect(() => withAppIntentsValidation(config, { directory: 'app-intents' })).toThrow( + /"directory"/ + ); + }); + + it('passes config through when configured correctly', () => { + const config = configWatching('app-intents'); + expect(withAppIntentsValidation(config, { directory: 'app-intents' })).toBe(config); + }); + + // `expo-modules-autolinking` resolves each watched entry against the app root, so these all + // name the same directory as `app-intents`. + it.each(['./app-intents', 'app-intents/', './app-intents/', 'app-intents/../app-intents'])( + 'accepts the equivalent watched path %s', + (watchedDirectory) => { + const config = configWatching(watchedDirectory); + expect(withAppIntentsValidation(config, { directory: 'app-intents' })).toBe(config); + } + ); + + // Watched directories are scanned recursively, so any ancestor works. + it.each(['.', './', 'src', 'src/native'])( + 'accepts the watched ancestor directory %s', + (watchedDirectory) => { + const config = configWatching(watchedDirectory); + expect(withAppIntentsValidation(config, { directory: 'src/native/app-intents' })).toBe( + config + ); + } + ); + + it('accepts a directory watched alongside unrelated ones', () => { + const config = configWatching('other', 'src'); + expect(withAppIntentsValidation(config, { directory: 'src/app-intents' })).toBe(config); + }); + + // `expo-router` also supports `src/app`, so intents there collide just as badly. + it.each([ + ['app/intents', "inside 'app/'"], + ['src/app/intents', "inside 'src/app/'"], + ])( + 'warns when the intents directory %s is inside the expo-router app directory', + (directory, expectedWarning) => { + const config = { + ...baseConfig, + experiments: { inlineModules: { watchedDirectories: [directory] } }, + }; + + expect(withAppIntents(config, { directory })).toBe(config); + expect(WarningAggregator.addWarningIOS).toHaveBeenCalledWith( + 'expo-app-intents', + expect.stringContaining(expectedWarning) + ); + } + ); + + it('does not warn about a directory that only shares a prefix with the router directory', () => { + const config = configWatching('app-intents'); + + expect(withAppIntents(config, { directory: 'app-intents' })).toBe(config); + expect(WarningAggregator.addWarningIOS).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/expo-app-intents/plugin/src/index.ts b/packages/expo-app-intents/plugin/src/index.ts new file mode 100644 index 00000000000000..3cb136719b278d --- /dev/null +++ b/packages/expo-app-intents/plugin/src/index.ts @@ -0,0 +1,4 @@ +import { Props } from './withAppIntents'; + +export default (props: Props = {}): [string, Props] => ['expo-app-intents', props]; +export { default as withAppIntents } from './withAppIntents'; diff --git a/packages/expo-app-intents/plugin/src/withAppIntents.ts b/packages/expo-app-intents/plugin/src/withAppIntents.ts new file mode 100644 index 00000000000000..12dbb275261db1 --- /dev/null +++ b/packages/expo-app-intents/plugin/src/withAppIntents.ts @@ -0,0 +1,129 @@ +import type { ExpoConfig } from 'expo/config'; +import { ConfigPlugin, WarningAggregator, createRunOncePlugin } from 'expo/config-plugins'; +import path from 'path'; + +const pkg = require('../../package.json'); + +export type Props = { + /** + * The watched directory containing app-target App Intents Swift files. + * @default 'app-intents' + */ + directory?: string; +}; + +const DEFAULT_DIRECTORY = 'app-intents'; + +/** + * The directories `expo-router` treats as the routes directory. It looks for `app/` at the project + * root and, when that is missing, for `src/app/`, so both collide with intents placed inside them. + */ +const ROUTER_APP_DIRECTORIES = ['app', 'src/app']; + +type ValidationConfig = Pick; + +/** + * Returns the directory that relative entries are resolved against. + */ +function projectRootOf(config: Pick): string { + return (config._internal?.projectRoot as string | undefined) ?? process.cwd(); +} + +/** + * Returns whether `directory` is `ancestor` itself or a directory nested inside it. + */ +function isSameOrInside(ancestor: string, directory: string, projectRoot: string): boolean { + const relativePath = path.relative( + path.resolve(projectRoot, ancestor), + path.resolve(projectRoot, directory) + ); + + if (relativePath === '') { + return true; + } + if (path.isAbsolute(relativePath)) { + return false; + } + // Only a whole `..` segment leaves `ancestor`. A segment such as `'..intents'` is an ordinary + // directory whose name begins with two dots. + return !relativePath.split(path.sep).includes('..'); +} + +/** + * Returns whether inline modules scan `directory`. + * + * `expo-modules-autolinking` scans every watched entry recursively, so any ancestor of the intents + * directory is a working configuration. + */ +function isWatchedDirectory( + watchedDirectories: string[], + directory: string, + projectRoot: string +): boolean { + return watchedDirectories.some((watchedDirectory) => + isSameOrInside(watchedDirectory, directory, projectRoot) + ); +} + +function buildsForIOS(config: Pick): boolean { + return !config.platforms || config.platforms.includes('ios'); +} + +export function withAppIntentsValidation( + config: T, + props: { directory: string } +): T { + const watchedDirectories = config.experiments?.inlineModules?.watchedDirectories; + const projectRoot = projectRootOf(config); + + if ( + !watchedDirectories || + !isWatchedDirectory(watchedDirectories, props.directory, projectRoot) + ) { + const watchedList = watchedDirectories?.length + ? watchedDirectories.map((directory) => `'${directory}'`).join(', ') + : 'nothing'; + + throw new Error( + `expo-app-intents cannot build the App Intents in '${props.directory}'. Apple's build-time ` + + `metadata extraction cannot see code inside pods, so those Swift files have to be ` + + `compiled into the iOS app target itself. Expo Inline Modules does that, but it only ` + + `scans the directories listed in expo.experiments.inlineModules.watchedDirectories, ` + + `which currently covers ${watchedList}.\n\n` + + `Add the directory to your app config and re-run prebuild:\n\n` + + ` "experiments": { "inlineModules": { "watchedDirectories": ["${props.directory}"] } }\n\n` + + `Watched directories are scanned recursively, so a parent directory works too. If your ` + + `intents already live in a directory that is watched, point the plugin at it instead with ` + + `the "directory" prop:\n\n` + + ` ["expo-app-intents", { "directory": "your-watched-directory" }]\n\n` + + `Or run \`npx expo-app-intents init\` to configure everything automatically.` + ); + } + return config; +} + +const withAppIntents: ConfigPlugin = (config, props) => { + if (!buildsForIOS(config)) { + return config; + } + + const directory = props?.directory ?? DEFAULT_DIRECTORY; + + // Normalised the same way as the watched-directory check, so `'./app/intents'` is recognised. + const projectRoot = projectRootOf(config); + const routerDirectory = ROUTER_APP_DIRECTORIES.find((routesDirectory) => + isSameOrInside(routesDirectory, directory, projectRoot) + ); + if (routerDirectory) { + WarningAggregator.addWarningIOS( + 'expo-app-intents', + `The configured intents directory '${directory}' is inside '${routerDirectory}/', which ` + + `expo-router treats as the routes directory. Use a top-level 'app-intents/' directory ` + + `instead.` + ); + } + + return withAppIntentsValidation(config, { directory }); +}; + +export default createRunOncePlugin(withAppIntents, pkg.name, pkg.version); diff --git a/packages/expo-app-intents/plugin/tsconfig.json b/packages/expo-app-intents/plugin/tsconfig.json new file mode 100644 index 00000000000000..9d87417cc8b573 --- /dev/null +++ b/packages/expo-app-intents/plugin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo-module-scripts/tsconfig.plugin", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./build", + "types": ["node"] + }, + "include": ["./src"], + "exclude": ["**/__mocks__/*", "**/__tests__/*"] +} diff --git a/packages/expo-app-intents/src/ExpoAppIntents.types.ts b/packages/expo-app-intents/src/ExpoAppIntents.types.ts new file mode 100644 index 00000000000000..300ce04a6b8fe4 --- /dev/null +++ b/packages/expo-app-intents/src/ExpoAppIntents.types.ts @@ -0,0 +1,59 @@ +/** + * A single recorded App Intent invocation. + * + * Invocations are persisted natively until removed with `removePendingInvocationAsync`, so + * delivery is at-least-once and handlers must be idempotent per `id`. + */ +export type AppIntentInvocation = { + /** + * Unique identifier of this invocation. Use it to remove the invocation after handling. + */ + id: string; + /** + * The invocation name passed to `await AppIntentDispatcher.shared.dispatch(name:params:)` in Swift. + */ + name: string; + /** + * Parameters passed from the native intent. + */ + params: Record; + /** + * Unix timestamp in milliseconds at which the intent ran. + */ + createdAt: number; +}; + +/** + * Handles a snapshot of pending invocations and, after the initial call, the new invocation + * that triggered this handler call. + */ +export type AppIntentsHandler = ( + pendingIntents: AppIntentInvocation[], + newIntent: AppIntentInvocation | null +) => void | Promise; + +/** + * An entity exposed to App Intents parameter queries. + */ +export type AppIntentEntity = { + /** + * Stable unique identifier. + */ + id: string; + /** + * Display name shown by Siri and the Shortcuts app, and matched against speech. + */ + title: string; + /** + * Optional secondary text shown in disambiguation UI. + */ + subtitle?: string; + /** + * Alternative spoken names that resolve to this entity. + */ + synonyms?: string[]; +}; + +export type ExpoAppIntentsModuleEvents = { + onIntent: (invocation: AppIntentInvocation) => void; +}; diff --git a/packages/expo-app-intents/src/ExpoAppIntentsModule.ts b/packages/expo-app-intents/src/ExpoAppIntentsModule.ts new file mode 100644 index 00000000000000..be2045ec015599 --- /dev/null +++ b/packages/expo-app-intents/src/ExpoAppIntentsModule.ts @@ -0,0 +1,18 @@ +import { NativeModule, requireOptionalNativeModule } from 'expo-modules-core'; + +import type { + AppIntentEntity, + AppIntentInvocation, + ExpoAppIntentsModuleEvents, +} from './ExpoAppIntents.types'; + +declare class ExpoAppIntentsNativeModule extends NativeModule { + getPendingInvocationsAsync(): Promise; + removePendingInvocationAsync(id: string): Promise; + clearPendingInvocationsAsync(): Promise; + setEntityCatalogAsync(kind: string, entities: AppIntentEntity[]): Promise; + getEntityCatalogAsync(kind: string): Promise; + refreshShortcutsAsync(): Promise; +} + +export default requireOptionalNativeModule('ExpoAppIntents'); diff --git a/packages/expo-app-intents/src/__tests__/index.test.ts b/packages/expo-app-intents/src/__tests__/index.test.ts new file mode 100644 index 00000000000000..7c3aad652a57fe --- /dev/null +++ b/packages/expo-app-intents/src/__tests__/index.test.ts @@ -0,0 +1,37 @@ +import * as AppIntents from '../index'; + +describe('expo-app-intents on unsupported platforms', () => { + it('reports unavailability', () => { + expect(AppIntents.isAvailable()).toBe(false); + }); + + it('returns empty pending invocations', async () => { + await expect(AppIntents.getPendingInvocationsAsync()).resolves.toEqual([]); + }); + + it('returns an empty entity catalog', async () => { + await expect(AppIntents.getEntityCatalogAsync('dish')).resolves.toEqual([]); + }); + + it('resolves no-op mutations', async () => { + await expect(AppIntents.removePendingInvocationAsync('x')).resolves.toBeUndefined(); + await expect(AppIntents.clearPendingInvocationsAsync()).resolves.toBeUndefined(); + await expect( + AppIntents.setEntityCatalogAsync('dish', [{ id: 'margherita', title: 'Margherita Pizza' }]) + ).resolves.toBeUndefined(); + }); + + it('rejects refreshShortcutsAsync with UnavailabilityError', async () => { + await expect(AppIntents.refreshShortcutsAsync()).rejects.toThrow(/not available/); + }); + + it('returns an inert subscription from addAppIntentListener', () => { + const subscription = AppIntents.addAppIntentListener(() => {}); + expect(typeof subscription.remove).toBe('function'); + subscription.remove(); + }); + + it('exports useAppIntents hook', () => { + expect(typeof AppIntents.useAppIntents).toBe('function'); + }); +}); diff --git a/packages/expo-app-intents/src/__tests__/useAppIntents.test.native.tsx b/packages/expo-app-intents/src/__tests__/useAppIntents.test.native.tsx new file mode 100644 index 00000000000000..a0981676c98f49 --- /dev/null +++ b/packages/expo-app-intents/src/__tests__/useAppIntents.test.native.tsx @@ -0,0 +1,154 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; + +import type { AppIntentInvocation } from '../ExpoAppIntents.types'; +import ExpoAppIntents from '../ExpoAppIntentsModule'; +import { useAppIntents } from '../index'; + +jest.mock('../ExpoAppIntentsModule', () => { + const listeners = new Set(); + return { + __esModule: true, + default: { + addListener: jest.fn((eventName, listener) => { + listeners.add(listener); + return { + remove: () => { + listeners.delete(listener); + }, + }; + }), + getPendingInvocationsAsync: jest.fn(async () => []), + }, + }; +}); + +function makeInvocation(id: string, name: string): AppIntentInvocation { + return { id, name, params: {}, createdAt: 0 }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +/** + * Lets every task that is not blocked on a still-pending promise run to completion, so a + * deferred promise resolved afterwards cannot win a race by accident of queueing order. + */ +async function flushMicrotasks() { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +const getPendingMock = ExpoAppIntents!.getPendingInvocationsAsync as jest.Mock; +const addListenerMock = ExpoAppIntents!.addListener as jest.Mock; + +function emitIntent(invocation: AppIntentInvocation) { + for (const call of addListenerMock.mock.calls) { + call[1](invocation); + } +} + +describe(useAppIntents, () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + getPendingMock.mockImplementation(async () => []); + }); + + it('continues delivery after the handler throws synchronously', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const cold = makeInvocation('cold', 'coldIntent'); + getPendingMock.mockResolvedValue([cold]); + + const handler = jest.fn().mockImplementationOnce(() => { + throw new Error('handler failed'); + }); + renderHook(() => useAppIntents(handler)); + + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect(handler).toHaveBeenCalledWith([cold], null); + emitIntent(makeInvocation('live', 'liveIntent')); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + }); + + it('delivers the initial snapshot before a live invocation that arrives during mount', async () => { + const cold = makeInvocation('cold', 'coldIntent'); + const live = makeInvocation('live', 'liveIntent'); + + // The initial pending read stays in flight until after the live invocation arrives, so a + // handler call for the live invocation could otherwise resolve first. + const initialRead = deferred(); + getPendingMock + .mockImplementationOnce(() => initialRead.promise) + .mockImplementation(async () => [cold, live]); + + const handler = jest.fn(); + renderHook(() => useAppIntents(handler)); + + emitIntent(live); + // Let the live invocation's delivery run as far as it can before the initial read resolves. + await flushMicrotasks(); + // The live invocation was persisted natively before its event fired, so it is part of the + // snapshot the initial read returns. + initialRead.resolve([cold, live]); + + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + expect(handler).toHaveBeenNthCalledWith(1, [cold], null); + expect(handler).toHaveBeenNthCalledWith(2, [cold, live], live); + }); + + it('delivers live invocations in arrival order', async () => { + const first = makeInvocation('first', 'firstIntent'); + const second = makeInvocation('second', 'secondIntent'); + + // The first live invocation's pending read resolves after the second one's, so delivery + // order must come from the hook, not from promise resolution order. + const firstRead = deferred(); + const firstDelivery = deferred(); + getPendingMock + .mockImplementationOnce(async () => []) + .mockImplementationOnce(() => firstRead.promise) + .mockImplementation(async () => [first, second]); + + const handler = jest.fn((_pending, newIntent) => + newIntent?.id === first.id ? firstDelivery.promise : undefined + ); + renderHook(() => useAppIntents(handler)); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + + emitIntent(first); + emitIntent(second); + // Let the second invocation's delivery run as far as it can while the first one's pending + // read is still in flight. + await flushMicrotasks(); + firstRead.resolve([first]); + + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + await flushMicrotasks(); + expect(handler).toHaveBeenCalledTimes(2); + firstDelivery.resolve(); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(3)); + expect(handler).toHaveBeenNthCalledWith(2, [first], first); + expect(handler).toHaveBeenNthCalledWith(3, [first, second], second); + }); + + it('does not redeliver an initial pending invocation as live', async () => { + const live = makeInvocation('live', 'liveIntent'); + getPendingMock.mockImplementation(async () => [live]); + + const handler = jest.fn(); + renderHook(() => useAppIntents(handler)); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + + emitIntent(live); + emitIntent(live); + + await flushMicrotasks(); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/expo-app-intents/src/index.ts b/packages/expo-app-intents/src/index.ts new file mode 100644 index 00000000000000..c93b7bfbd058b2 --- /dev/null +++ b/packages/expo-app-intents/src/index.ts @@ -0,0 +1,243 @@ +import { type EventSubscription, UnavailabilityError } from 'expo-modules-core'; +import { useEffect, useRef } from 'react'; + +import type { + AppIntentEntity, + AppIntentInvocation, + AppIntentsHandler, +} from './ExpoAppIntents.types'; +import ExpoAppIntents from './ExpoAppIntentsModule'; + +export type * from './ExpoAppIntents.types'; + +const MAX_SEEN_INVOCATION_IDS = 100; + +/** + * Returns whether App Intents are available on this device. + * Returns `false` on Android, and web. + * @platform ios + */ +export function isAvailable(): boolean { + return ExpoAppIntents != null; +} + +/** + * Adds a listener invoked for live App Intent invocations dispatched while JavaScript is + * observing. + * + * > Use [`getPendingInvocationsAsync()`](#appintentsgetpendinginvocationsasync) or + * > [`useAppIntents()`](#appintentsuseappintentshandler) to read invocations recorded while + * > JavaScript was not running. + * @platform ios + */ +export function addAppIntentListener( + listener: (invocation: AppIntentInvocation) => void +): EventSubscription { + if (!ExpoAppIntents) { + return { remove() {} }; + } + return ExpoAppIntents.addListener('onIntent', listener); +} + +function callAppIntentsHandler( + handler: AppIntentsHandler, + pendingIntents: AppIntentInvocation[], + newIntent: AppIntentInvocation | null +) { + return Promise.resolve() + .then(() => handler(pendingIntents, newIntent)) + .catch((error: unknown) => { + console.warn('Unhandled error in useAppIntents handler.', error); + }); +} + +/** + * Calls `handler` once with the pending invocations recorded while JavaScript was cold, then + * again for every new invocation received while the component is mounted. + * + * `newIntent` is `null` for the initial pending snapshot. Later calls include the current + * pending snapshot and the new invocation that triggered the call. The initial call is always + * delivered first, and new invocations are delivered one at a time in arrival order. + * Pending invocations are not removed automatically; call + * [`removePendingInvocationAsync(id)`](#appintentsremovependinginvocationasyncid) + * after handling each one. The queue holds at most 100 invocations, and once it is full the oldest + * are dropped to make room, so a handler that never removes them does eventually lose invocations. + * + * The handler is called with an empty snapshot, and never again, when App Intents are unavailable. + * @platform ios + */ +export function useAppIntents(handler: AppIntentsHandler): void { + const handlerRef = useRef(handler); + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + useEffect(() => { + let isMounted = true; + const seenLiveInvocationIds = new Set(); + let deliveryQueue: Promise = Promise.resolve(); + const enqueue = (deliver: () => Promise) => { + deliveryQueue = deliveryQueue.then(deliver); + }; + + const notify = ( + pendingIntents: AppIntentInvocation[], + newIntent: AppIntentInvocation | null + ) => { + if (!isMounted) { + return; + } + return callAppIntentsHandler(handlerRef.current, pendingIntents, newIntent); + }; + + const deliverNewIntent = async (newIntent: AppIntentInvocation) => { + try { + const pendingIntents = await getPendingInvocationsAsync(); + await notify(pendingIntents.length > 0 ? pendingIntents : [newIntent], newIntent); + } catch (error) { + if (isMounted) { + console.error('Could not read pending App Intents invocations.', error); + await notify([newIntent], newIntent); + } + } + }; + + const deliverInitialPendingIntents = async () => { + try { + const pendingIntents = await getPendingInvocationsAsync(); + const initialPendingIntents = pendingIntents.filter( + (invocation) => !seenLiveInvocationIds.has(invocation.id) + ); + initialPendingIntents.forEach(({ id }) => seenLiveInvocationIds.add(id)); + await notify(initialPendingIntents, null); + } catch (error) { + if (isMounted) { + console.error('Could not read pending App Intents invocations.', error); + await notify([], null); + } + } + }; + + // Attach the live listener first so an invocation cannot arrive between reading pending + // invocations and subscribing to future ones. + const subscription = addAppIntentListener((newIntent) => { + if (seenLiveInvocationIds.has(newIntent.id)) { + return; + } + seenLiveInvocationIds.add(newIntent.id); + if (seenLiveInvocationIds.size > MAX_SEEN_INVOCATION_IDS) { + seenLiveInvocationIds.delete(seenLiveInvocationIds.values().next().value!); + } + enqueue(() => deliverNewIntent(newIntent)); + }); + + enqueue(() => deliverInitialPendingIntents()); + + return () => { + isMounted = false; + subscription.remove(); + }; + }, []); +} + +/** + * Returns invocations that have not been removed from the pending queue yet, oldest first. + * Resolves with an empty array when App Intents are unavailable. + * + * At most 100 invocations are kept. An app that never removes them keeps only the newest 100. + * + * Rejects when the stored queue cannot be read, which means the invocations waiting in it are not + * delivered. The queue starts empty afterwards, so a later call succeeds. + * @platform ios + */ +export async function getPendingInvocationsAsync(): Promise { + if (!ExpoAppIntents) { + return []; + } + return ExpoAppIntents.getPendingInvocationsAsync(); +} + +/** + * Removes a handled invocation so it is no longer delivered or returned as pending. + * Does nothing when App Intents are unavailable. + * + * Rejects when the stored queue cannot be read or written, so a failure to forget a handled + * invocation is not mistaken for success. A rejection caused by an unreadable queue leaves nothing + * pending at all: the unreadable data is set aside, so every invocation that was waiting in it is + * gone, not only this one. + * @platform ios + */ +export async function removePendingInvocationAsync(id: string): Promise { + if (!ExpoAppIntents) { + return; + } + return ExpoAppIntents.removePendingInvocationAsync(id); +} + +/** + * Removes all pending invocations. + * Does nothing when App Intents are unavailable. + * @platform ios + */ +export async function clearPendingInvocationsAsync(): Promise { + if (!ExpoAppIntents) { + return; + } + return ExpoAppIntents.clearPendingInvocationsAsync(); +} + +/** + * Replaces the entity catalog of the given kind and asks the system to re-train + * parameterized shortcut phrases against the new values. + * + * The native store is UserDefaults-backed, so it's recommended to keep catalogs compact. For large + * datasets such as thousands of contacts, songs, or other items, store the full + * data in your app and publish only the subset that Siri and Shortcuts need. + * + * When `kind` or a provided entity is invalid, the whole catalog is rejected, and the previous + * one kept. The `kind` is invalid when it is empty or whitespace-only. An entity is invalid when + * its `id` or `title` is empty or whitespace-only, or when another entity in the catalog has the + * same `id`. + * @platform ios + */ +export async function setEntityCatalogAsync( + kind: string, + entities: AppIntentEntity[] +): Promise { + if (!ExpoAppIntents) { + return; + } + return ExpoAppIntents.setEntityCatalogAsync(kind, entities); +} + +/** + * Returns the current entity catalog of the given kind. + * Resolves with an empty array when the kind was never published, and when App Intents are + * unavailable. + * + * Rejects when the stored catalog cannot be read. + * @platform ios + */ +export async function getEntityCatalogAsync(kind: string): Promise { + if (!ExpoAppIntents) { + return []; + } + return ExpoAppIntents.getEntityCatalogAsync(kind); +} + +/** + * Asks the system to re-evaluate App Shortcut phrases and parameter values. + * + * Throws `UnavailabilityError` when App Intents are unavailable, and throws when they are available + * but the app has no `AppShortcutsProvider`, and so nothing to refresh. Publishing a catalog with + * [`setEntityCatalogAsync()`](#appintentssetentitycatalogasynckind-entities) also refreshes + * shortcuts. + * @platform ios + */ +export async function refreshShortcutsAsync(): Promise { + if (!ExpoAppIntents) { + throw new UnavailabilityError('expo-app-intents', 'refreshShortcutsAsync'); + } + return ExpoAppIntents.refreshShortcutsAsync(); +} diff --git a/packages/expo-app-intents/tsconfig.all.json b/packages/expo-app-intents/tsconfig.all.json new file mode 100644 index 00000000000000..e952403770f319 --- /dev/null +++ b/packages/expo-app-intents/tsconfig.all.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.json" + }, + { + "path": "./plugin" + } + ] +} diff --git a/packages/expo-app-intents/tsconfig.json b/packages/expo-app-intents/tsconfig.json new file mode 100644 index 00000000000000..55589cf2d15c96 --- /dev/null +++ b/packages/expo-app-intents/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo-module-scripts/tsconfig.base", + "compilerOptions": { + "outDir": "./build", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["./src"], + "exclude": ["**/__mocks__/*", "**/__tests__/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a8cc878c17554..701a225a55666d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: expo: specifier: workspace:* version: link:../../packages/expo + expo-app-intents: + specifier: workspace:* + version: link:../../packages/expo-app-intents expo-app-metrics: specifier: workspace:* version: link:../../packages/expo-app-metrics @@ -3698,6 +3701,34 @@ importers: specifier: workspace:* version: link:../expo-module-scripts + packages/expo-app-intents: + dependencies: + react: + specifier: '*' + version: 19.2.3 + react-native: + specifier: 0.86.0 + version: 0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) + devDependencies: + '@testing-library/react-native': + specifier: ^13.3.0 + version: 13.3.3(jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.20.1)(typescript@6.0.3)))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react-test-renderer@19.2.3(react@19.2.3))(react@19.2.3) + '@types/jest': + specifier: ^29.2.1 + version: 29.5.14 + '@types/node': + specifier: ^22.14.0 + version: 22.20.1 + '@types/react': + specifier: ~19.2.0 + version: 19.2.14 + expo: + specifier: workspace:* + version: link:../expo + expo-module-scripts: + specifier: workspace:* + version: link:../expo-module-scripts + packages/expo-app-metrics: dependencies: expo-updates-interface: