diff --git a/.github/actions/prepare-model-env/pick.sh b/.github/actions/prepare-model-env/pick.sh
index abab28f20961c2..85d0114e0e03c7 100755
--- a/.github/actions/prepare-model-env/pick.sh
+++ b/.github/actions/prepare-model-env/pick.sh
@@ -50,6 +50,19 @@ normalize_token() {
printf '%s' "$v"
}
+# Classify an arbitrary CLI log for a caller. The verify canary hits the
+# same failure modes this probe does, but later in the job — after this
+# step already passed — so its reporter cannot lean on model-env-reason.
+# One classifier, exposed here, keeps the two verdicts from drifting.
+if [ "${1:-}" = --classify ]; then
+ if [ -z "${2:-}" ] || [ ! -f "$2" ]; then
+ echo other
+ exit 0
+ fi
+ classify_failure "$2"
+ exit 0
+fi
+
if [ "${1:-}" = --self-test ]; then
tmp=$(mktemp)
printf '%s\n' 'HTTP 429 rate_limit_error: rate limit exceeded' >"$tmp"
@@ -62,6 +75,10 @@ if [ "${1:-}" = --self-test ]; then
[ "$(classify_failure "$tmp")" = auth ]
printf '%s\n' 'ECONNRESET connection reset by peer' >"$tmp"
[ "$(classify_failure "$tmp")" = other ]
+ printf '%s\n' 'API Error: 529 Overloaded. This is a server-side issue, usually temporary.' >"$tmp"
+ [ "$(classify_failure "$tmp")" = other ]
+ [ "$(bash "$0" --classify "$tmp")" = other ]
+ [ "$(bash "$0" --classify /nonexistent-classify-input)" = other ]
rm -f "$tmp"
[ "$(token_shape "sk-ant-oat01-aaaa")" = oat ]
[ "$(token_shape "sk-ant-api03-aaaa")" = api ]
diff --git a/.github/workflows/agent-commands.yml b/.github/workflows/agent-commands.yml
index 042a7e396d8b66..2f27b9f9800a1b 100644
--- a/.github/workflows/agent-commands.yml
+++ b/.github/workflows/agent-commands.yml
@@ -1089,17 +1089,35 @@ jobs:
else
canary_prompt="Write the single word ok to .verify-out/.permcheck. Say nothing else."
fi
+ set +e
claude -p "$canary_prompt" \
--model "$VERIFY_MODEL" \
--disallowedTools "$DENIED" --strict-mcp-config \
- --allowedTools "$ALLOWED" > "$RUNNER_TEMP/permcheck.log" 2>&1 || true
+ --allowedTools "$ALLOWED" > "$RUNNER_TEMP/permcheck.log" 2>&1
+ canary_rc=$?
+ set -e
if [ ! -f .verify-out/.permcheck ]; then
+ # Also record WHICH failure this was, for the thread comment.
+ # The reporter's cause line comes from model-env-reason, and the
+ # credential probe PASSED by the time this runs — so without a
+ # verdict of its own, a canary death posts a bare "failed" with
+ # no cause (2026-08-18, PR 46816: a 529 between the probe and
+ # the canary). The verdict is grounded, not guessed: exit 0
+ # with no file means the CLI ran and the rule did not match;
+ # non-zero means the CLI itself failed, and the probe's own
+ # classifier names the flavor from the kept output.
+ if [ "$canary_rc" -eq 0 ]; then
+ echo permission > "$RUNNER_TEMP/canary-reason"
+ else
+ bash .github/actions/prepare-model-env/pick.sh --classify "$RUNNER_TEMP/permcheck.log" > "$RUNNER_TEMP/canary-reason" || true
+ fi
echo "::error::Preflight canary did not write .verify-out/.permcheck, so the run stopped before spending a sandbox. This is EITHER a file-permission rule that does not match OR the CLI failing to run at all (credential, model access, outage). The CLI output below says which:"
sed -n '1,40p' "$RUNNER_TEMP/permcheck.log" || true
echo "::error::--allowedTools was: $ALLOWED"
exit 1
fi
if [ "$FIX_MODE" = "true" ] && [ ! -f .permcheck-checkout ]; then
+ echo permission > "$RUNNER_TEMP/canary-reason"
echo "::error::Preflight canary wrote .verify-out but not the checkout root, so the checkout-wide Edit rule is not matching — the agent would do its work and then fail to mirror it. Stopped before spending a sandbox. The CLI output:"
sed -n '1,40p' "$RUNNER_TEMP/permcheck.log" || true
echo "::error::--allowedTools was: $ALLOWED"
@@ -1736,12 +1754,27 @@ jobs:
agent=$(git rev-parse HEAD)
echo "origin/main moved under the run (${base:0:12} -> ${tip:0:12}); replaying ${agent:0:12} onto it"
git checkout --detach origin/main
- if ! git cherry-pick "$agent"; then
+ # cherry-pick CREATES a commit, so it needs the same committer
+ # identity the publish commit got — without it, git dies with
+ # "empty ident name" and the first moved-main replay in the
+ # wild was misreported as "conflict: unknown" (run 32167073927).
+ if ! pick_out=$(git -c user.name='expo-bot' -c user.email='expo-bot@users.noreply.github.com' \
+ cherry-pick "$agent" 2>&1); then
conflicted=$(git diff --name-only --diff-filter=U | tr '\n' ' ')
git cherry-pick --abort >/dev/null 2>&1 || true
- echo "::error::could not replay the publish commit onto origin/main (conflict: ${conflicted:-unknown})."
+ echo "ALIGN_FAIL=replay" >> "$RUNNER_TEMP/align.env"
+ # Only a failure with unmerged paths is a conflict. Anything
+ # else is git refusing for its own reasons — show its words
+ # instead of diagnosing a conflict that did not happen.
+ if [ -n "$conflicted" ]; then
+ echo "::error::could not replay the publish commit onto origin/main (conflict: $conflicted)."
+ else
+ echo "::error::could not replay the publish commit onto origin/main (not a content conflict — git said):"
+ printf '%s\n' "$pick_out"
+ fi
return 1
fi
+ printf '%s\n' "$pick_out"
return 0
}
local_workflows_tree() {
@@ -1808,6 +1841,12 @@ jobs:
if [ "$fail_kind" = "stale_fork" ]; then
gh issue comment "$ISSUE_NUMBER" --body "⛔ \`$command_name\` prepared the $change_name but the bot fork's workflow files do not match current \`main\`, and the dedicated sync job is not running. Land on \`main\` (or re-run \`sync-expo-bot-fork\`) and re-trigger. The outcome comment still stands. [Run log]($RUN_URL)"
echo "::error::stale fork with no in-flight sync; refusing to push."
+ elif [ "$fail_kind" = "replay" ]; then
+ # Without its own branch this fell through to the workflow-tree
+ # message below, which told the thread a fork-sync story about
+ # a replay failure (run 32167073927).
+ gh issue comment "$ISSUE_NUMBER" --body "⛔ \`$command_name\` prepared the $change_name, but \`main\` moved under the run and the change could not be replayed onto the new tip. Re-trigger \`$command_name\` to regenerate it against current \`main\`. The outcome comment still stands and describes the change. [Run log]($RUN_URL)"
+ echo "::error::replay onto moved origin/main failed; refusing to push."
else
gh issue comment "$ISSUE_NUMBER" --body "⛔ \`$command_name\` prepared the $change_name but could not line its workflow files up with the bot fork in time (the dedicated sync job on \`expo/expo\` main is what fast-forwards the fork). Re-run once that job has finished. The outcome comment still stands. [Run log]($RUN_URL)"
echo "::error::could not match $fork .github/workflows to this commit; refusing to push."
@@ -2101,16 +2140,26 @@ jobs:
cancelled) outcome="was cancelled" ;;
*) outcome="failed" ;;
esac
- cause=""
+ # Two sources for the cause, one vocabulary. model-env-reason
+ # covers a credential probe that failed; canary-reason covers a
+ # probe that PASSED followed by a preflight canary that did not —
+ # a window a 529 fell into on 2026-08-18 (PR 46816), which posted
+ # a bare "failed" with no cause and no hint that a plain retry
+ # would work.
+ reason=""
if [ "$MODEL_ENV" = "failure" ]; then
reason=$(cat "$RUNNER_TEMP/model-env-reason" 2>/dev/null || echo unprobed)
- case "$reason" in
- auth) cause=" The cause is its model credential, which was rejected — a maintainer needs to take a look." ;;
- usage) cause=" The cause is a temporary model usage limit; nothing is misconfigured, so trying again later should work." ;;
- other) cause=" The cause is a failure reaching the model — a network fault, a timeout, or a provider outage." ;;
- *) cause=" It failed before reaching the model, so this is a workflow problem rather than a credential one." ;;
- esac
+ elif [ -s "$RUNNER_TEMP/canary-reason" ]; then
+ reason=$(cat "$RUNNER_TEMP/canary-reason")
fi
+ case "$reason" in
+ "") cause="" ;;
+ auth) cause=" The cause is its model credential, which was rejected — a maintainer needs to take a look." ;;
+ usage) cause=" The cause is a temporary model usage limit; nothing is misconfigured, so trying again later should work." ;;
+ other) cause=" The cause is a failure reaching the model — a network fault, a timeout, or a provider outage; nothing is misconfigured, so trying again later should work." ;;
+ permission) cause=" The cause is the workflow's file-permission rules: the preflight write check was refused, so a maintainer needs to fix the \`--allowedTools\` rules first — a plain retry will fail the same way." ;;
+ *) cause=" It failed before reaching the model, so this is a workflow problem rather than a credential one." ;;
+ esac
# Point at the salvage when there is one. An interrupted run often
# established real things before it died, and a maintainer should be
# told where they are rather than left to assume nothing survived.
diff --git a/apps/observe-tester/app/(tabs)/examples/_layout.tsx b/apps/observe-tester/app/(tabs)/examples/_layout.tsx
index 1db064a2590d98..8e253edab7061f 100644
--- a/apps/observe-tester/app/(tabs)/examples/_layout.tsx
+++ b/apps/observe-tester/app/(tabs)/examples/_layout.tsx
@@ -15,6 +15,7 @@ export default function ExamplesLayout() {
+
);
}
diff --git a/apps/observe-tester/app/(tabs)/examples/event-flood.tsx b/apps/observe-tester/app/(tabs)/examples/event-flood.tsx
new file mode 100644
index 00000000000000..12f21e4046d318
--- /dev/null
+++ b/apps/observe-tester/app/(tabs)/examples/event-flood.tsx
@@ -0,0 +1,85 @@
+import AppMetrics from 'expo-app-metrics';
+import { Observe } from 'expo-observe';
+import { useState } from 'react';
+import { Platform, ScrollView, StyleSheet, Text } from 'react-native';
+
+import { Button } from '@/components/Button';
+import { useTheme } from '@/utils/theme';
+
+export default function EventFlood() {
+ const theme = useTheme();
+ const [status, setStatus] = useState(null);
+
+ function logFlood(count: number) {
+ const startedAt = performance.now();
+
+ for (let index = 0; index < count; index++) {
+ Observe.logEvent('stress.driver_event', {
+ severity: 'info',
+ body: 'Driver telemetry update received',
+ attributes: {
+ driverId: 'driver_48291',
+ vehicleId: 'vehicle_731',
+ tripId: 'trip_2026_08_14_1842',
+ mode: 'delivery',
+ isOnline: true,
+ speedKph: 42.7,
+ location: {
+ latitude: 37.7751,
+ longitude: -122.4193,
+ accuracyMeters: 8.4,
+ },
+ route: ['warehouse', 'pickup', 'dropoff'],
+ index,
+ },
+ });
+ }
+
+ setStatus(
+ `Logged ${count.toLocaleString()} events in ${Math.round(performance.now() - startedAt).toLocaleString()} ms`
+ );
+ }
+
+ async function clearStoredEntries() {
+ try {
+ await AppMetrics.clearStoredEntries();
+ setStatus('Cleared stored entries');
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ setStatus(`Failed to clear stored entries: ${message}`);
+ }
+ }
+
+ return (
+
+
+ Log a burst of driver telemetry events synchronously to reproduce high-volume integrations.
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ padding: 20,
+ paddingBottom: Platform.select({ ios: 30, android: 150 }),
+ },
+ description: {
+ fontSize: 14,
+ marginBottom: 20,
+ },
+ status: {
+ fontSize: 14,
+ fontWeight: '600',
+ },
+});
diff --git a/apps/observe-tester/app/(tabs)/examples/index.tsx b/apps/observe-tester/app/(tabs)/examples/index.tsx
index 9939401ef06bd7..1fd9daf98c742f 100644
--- a/apps/observe-tester/app/(tabs)/examples/index.tsx
+++ b/apps/observe-tester/app/(tabs)/examples/index.tsx
@@ -33,6 +33,11 @@ export default function ExamplesIndex() {
description="modal / formSheet / pageSheet presentations"
onPress={() => router.push('/examples/modals')}
/>
+ router.push('/examples/event-flood')}
+ />
);
}
diff --git a/docs/pages/guides/overview.mdx b/docs/pages/guides/overview.mdx
index d06ad0302884f3..e1489952b8252c 100644
--- a/docs/pages/guides/overview.mdx
+++ b/docs/pages/guides/overview.mdx
@@ -11,19 +11,19 @@ import { CpuChip01Icon } from '@expo/styleguide-icons/outline/CpuChip01Icon';
This section contains information about the development with Expo and Expo Application Services (EAS):
-## Development process
+## Development process
Learn about the process of [building an app with Expo](/workflow/overview/) to help understand the mental model of the core development loop. This section also dives into additional configurations and workflows you may require during the development process to help you develop, deploy, and maintain your app. It contains in-depth information about [app config](/workflow/configuration/), [permissions](/guides/permissions/), [universal links](/linking/into-your-app/), [custom native code](/workflow/continuous-native-generation/), [web](/workflow/web/), and more.
-## Expo Router
+## Expo Router
Learn about using different navigation functionalities from the [Expo Router](/router/introduction/) library. It also covers a comprehensive [Hooks API](/versions/latest/sdk/router/#hooks) that the library provides and other aspects of navigation such as [Authentication](/router/advanced/authentication/), [Redirects](/router/reference/redirects/), [Testing](/router/reference/testing/), and more.
-## Expo Modules API
+## Expo Modules API
Learn how to add and use native modules in your app using [Expo Modules API](/modules/overview/).
-## Tutorials
+## Tutorials
If you're looking for step-by-step tutorials for Expo and EAS, see the [Tutorial section](/tutorial/overview/) which includes comprehensive tutorials for both [building apps with Expo](/tutorial/introduction/) and [using EAS services](/tutorial/eas/introduction/).
diff --git a/docs/pages/router/advanced/authentication-rewrites.mdx b/docs/pages/router/advanced/authentication-rewrites.mdx
index 91dd7549fa626e..eada4fc5517bea 100644
--- a/docs/pages/router/advanced/authentication-rewrites.mdx
+++ b/docs/pages/router/advanced/authentication-rewrites.mdx
@@ -34,14 +34,14 @@ It's common to restrict specific routes to users who are not authenticated. This
[
'app/sign-in.tsx',
- Always accessible
+ Always accessible
,
],
['app/(app)/_layout.tsx', Protects child routes],
[
'app/(app)/index.tsx',
- Requires authorization
+ Requires authorization
,
],
]}
@@ -305,7 +305,7 @@ Another common pattern is to render a sign-in modal over the top of the app. Thi
[
'app/(app)/(root)/index.tsx',
- Requires authorization
+ Requires authorization
,
],
]}
diff --git a/docs/pages/router/advanced/authentication.mdx b/docs/pages/router/advanced/authentication.mdx
index f419640dd37c0e..185d04a4c2aa89 100644
--- a/docs/pages/router/advanced/authentication.mdx
+++ b/docs/pages/router/advanced/authentication.mdx
@@ -27,13 +27,13 @@ With Expo Router, all routes are always defined and accessible. You can use runt
[
'src/app/sign-in.tsx',
- Always accessible
+ Always accessible
,
],
[
'src/app/(app)/_layout.tsx',
- Requires authorization
+ Requires authorization
,
],
['src/app/(app)/index.tsx', Should be protected by the (app)/_layout],
@@ -335,7 +335,7 @@ Another common pattern is to render a sign-in modal over the top of the app. Thi
[
'src/app/(app)/(root)/index.tsx',
- Requires authorization
+ Requires authorization
,
],
]}
diff --git a/docs/pages/router/basics/common-navigation-patterns.mdx b/docs/pages/router/basics/common-navigation-patterns.mdx
index c471d1b243be79..c68e7612b4f2c7 100644
--- a/docs/pages/router/basics/common-navigation-patterns.mdx
+++ b/docs/pages/router/basics/common-navigation-patterns.mdx
@@ -203,13 +203,13 @@ For example, consider the following navigation tree in which you have a bottom t
[
'src/app/(tabs)/index.tsx',
- Protected
+ Protected
,
],
[
'src/app/(tabs)/settings.tsx',
- Protected
+ Protected
,
],
['src/app/sign-in.tsx'],
@@ -217,7 +217,7 @@ For example, consider the following navigation tree in which you have a bottom t
[
'src/app/modal.tsx',
- Protected
+ Protected
,
],
]}
diff --git a/docs/pages/versions/unversioned/sdk/safe-area-context.mdx b/docs/pages/versions/unversioned/sdk/safe-area-context.mdx
index d02d509b26fcc3..5e20bd22c200f8 100644
--- a/docs/pages/versions/unversioned/sdk/safe-area-context.mdx
+++ b/docs/pages/versions/unversioned/sdk/safe-area-context.mdx
@@ -107,7 +107,10 @@ function HookComponent() {
-
+
[`EdgeInsets`](#edgeinsets)
diff --git a/docs/pages/versions/v54.0.0/sdk/safe-area-context.mdx b/docs/pages/versions/v54.0.0/sdk/safe-area-context.mdx
index d02d509b26fcc3..5e20bd22c200f8 100644
--- a/docs/pages/versions/v54.0.0/sdk/safe-area-context.mdx
+++ b/docs/pages/versions/v54.0.0/sdk/safe-area-context.mdx
@@ -107,7 +107,10 @@ function HookComponent() {
-
+
[`EdgeInsets`](#edgeinsets)
diff --git a/docs/pages/versions/v55.0.0/sdk/safe-area-context.mdx b/docs/pages/versions/v55.0.0/sdk/safe-area-context.mdx
index d02d509b26fcc3..5e20bd22c200f8 100644
--- a/docs/pages/versions/v55.0.0/sdk/safe-area-context.mdx
+++ b/docs/pages/versions/v55.0.0/sdk/safe-area-context.mdx
@@ -107,7 +107,10 @@ function HookComponent() {
-
+
[`EdgeInsets`](#edgeinsets)
diff --git a/docs/pages/versions/v56.0.0/sdk/safe-area-context.mdx b/docs/pages/versions/v56.0.0/sdk/safe-area-context.mdx
index d02d509b26fcc3..5e20bd22c200f8 100644
--- a/docs/pages/versions/v56.0.0/sdk/safe-area-context.mdx
+++ b/docs/pages/versions/v56.0.0/sdk/safe-area-context.mdx
@@ -107,7 +107,10 @@ function HookComponent() {
-
+
[`EdgeInsets`](#edgeinsets)
diff --git a/docs/pages/versions/v57.0.0/sdk/safe-area-context.mdx b/docs/pages/versions/v57.0.0/sdk/safe-area-context.mdx
index d02d509b26fcc3..5e20bd22c200f8 100644
--- a/docs/pages/versions/v57.0.0/sdk/safe-area-context.mdx
+++ b/docs/pages/versions/v57.0.0/sdk/safe-area-context.mdx
@@ -107,7 +107,10 @@ function HookComponent() {
-
+
[`EdgeInsets`](#edgeinsets)
diff --git a/docs/scenes/develop/development-builds/MethodSelectCard.tsx b/docs/scenes/develop/development-builds/MethodSelectCard.tsx
index c2caba441c2bc9..03e5bd8e12593c 100644
--- a/docs/scenes/develop/development-builds/MethodSelectCard.tsx
+++ b/docs/scenes/develop/development-builds/MethodSelectCard.tsx
@@ -25,6 +25,7 @@ export function MethodSelectCard({ Icon, title, description, isSelected, onClick
isSelected ? 'bg-linear-to-b from-palette-blue3 to-palette-blue4' : 'bg-subtle'
)}>
diff --git a/packages/@expo/cli/CHANGELOG.md b/packages/@expo/cli/CHANGELOG.md
index 0bc36795cae9a5..1863de8f2ccfd3 100644
--- a/packages/@expo/cli/CHANGELOG.md
+++ b/packages/@expo/cli/CHANGELOG.md
@@ -23,6 +23,7 @@
### 🐛 Bug fixes
+- Serve relative manifest URLs only when the client itself sends the RFC 7239 `Forwarded` header, so that proxied requests from clients without relative-URL support, like released Expo Go versions through the WS tunnel, keep absolute URLs. ([#48997](https://github.com/expo/expo/pull/48997) by [@expo-bot](https://github.com/expo-bot))
- Fail when `--private-key-path` is passed without `updates.codeSigningCertificate` in the resolved app config, instead of ignoring the flag and continuing without signing.
- Show the Xcode build log path when `run:ios` fails. ([#48624](https://github.com/expo/expo/pull/48624) by [@ramonclaudio](https://github.com/ramonclaudio))
- [Internal] Fix `LogStream.destroy()` racing a pending write and dropping log data ([#47181](https://github.com/expo/expo/pull/47181) by [@kitten](https://github.com/kitten))
diff --git a/packages/@expo/cli/src/start/server/__tests__/UrlCreator-test.ts b/packages/@expo/cli/src/start/server/__tests__/UrlCreator-test.ts
index 8bd7d9d4244511..3ec78afecc40d0 100644
--- a/packages/@expo/cli/src/start/server/__tests__/UrlCreator-test.ts
+++ b/packages/@expo/cli/src/start/server/__tests__/UrlCreator-test.ts
@@ -61,7 +61,7 @@ describe('constructDevClientUrl', () => {
expect(
createDefaultCreator().constructDevClientUrl({
scheme: 'bacon',
- forwarded: { authority: 'proxy.test:4443', protocol: 'https' },
+ forwarded: { authority: 'proxy.test:4443', protocol: 'https', viaForwardedHeader: true },
})
).toMatchInlineSnapshot(
`"bacon://expo-development-client/?url=https%3A%2F%2Fproxy.test%3A4443"`
@@ -91,7 +91,7 @@ describe('constructUrl', () => {
expect(
createDefaultCreator().constructUrl({
scheme: 'http',
- forwarded: { authority: 'proxy.test:4443', protocol: undefined },
+ forwarded: { authority: 'proxy.test:4443', protocol: undefined, viaForwardedHeader: true },
})
).toMatchInlineSnapshot(`"http://proxy.test:4443"`);
});
@@ -99,7 +99,7 @@ describe('constructUrl', () => {
expect(
createDefaultCreator().constructUrl({
scheme: 'http',
- forwarded: { authority: 'proxy.test', protocol: undefined },
+ forwarded: { authority: 'proxy.test', protocol: undefined, viaForwardedHeader: true },
})
).toMatchInlineSnapshot(`"http://proxy.test"`);
});
@@ -108,21 +108,21 @@ describe('constructUrl', () => {
createDefaultCreator().constructUrl({
hostname: 'foobar.dev',
hostType: 'tunnel',
- forwarded: { authority: 'proxy.test:4443', protocol: undefined },
+ forwarded: { authority: 'proxy.test:4443', protocol: undefined, viaForwardedHeader: true },
})
).toMatchInlineSnapshot(`"http://proxy.test:4443"`);
});
it(`ignores a forwarded protocol without an authority`, () => {
expect(
createDefaultCreator().constructUrl({
- forwarded: { authority: undefined, protocol: 'https' },
+ forwarded: { authority: undefined, protocol: 'https', viaForwardedHeader: false },
})
).toMatchInlineSnapshot(`"http://100.100.1.100:8081"`);
});
it(`keeps the proxy url over the forwarded authority`, () => {
expect(
createDefaultCreator({ getProxyUrl: () => 'http://expo.dev' }).constructUrl({
- forwarded: { authority: 'proxy.test:4443', protocol: undefined },
+ forwarded: { authority: 'proxy.test:4443', protocol: undefined, viaForwardedHeader: true },
})
).toMatchInlineSnapshot(`"http://expo.dev"`);
});
diff --git a/packages/@expo/cli/src/start/server/middleware/ManifestMiddleware.ts b/packages/@expo/cli/src/start/server/middleware/ManifestMiddleware.ts
index 8e786098ae5ef1..bcbaba571a7c13 100644
--- a/packages/@expo/cli/src/start/server/middleware/ManifestMiddleware.ts
+++ b/packages/@expo/cli/src/start/server/middleware/ManifestMiddleware.ts
@@ -130,8 +130,9 @@ export abstract class ManifestMiddleware<
const user = await getUserAsync();
const username = getActorDisplayName(user);
- // We emit relative URLs if the client reported a forwarded authority
- const shouldUseRelativeManifestUrls = !!forwarded?.authority;
+ // We emit relative URLs only if the client itself reported the authority,
+ // via a `Forwarded` header to differentiate older/newer clients
+ const shouldUseRelativeManifestUrls = !!forwarded?.viaForwardedHeader;
// `hostUri` and `debuggerHost` can only hold an authority, so they can't be made relative
const hostUri = forwarded?.authority ?? this.options.constructUrl({ scheme: '', hostname });
diff --git a/packages/@expo/cli/src/start/server/middleware/__tests__/ManifestMiddleware-test.ts b/packages/@expo/cli/src/start/server/middleware/__tests__/ManifestMiddleware-test.ts
index 0a16d1009ccf22..b25db60fb16946 100644
--- a/packages/@expo/cli/src/start/server/middleware/__tests__/ManifestMiddleware-test.ts
+++ b/packages/@expo/cli/src/start/server/middleware/__tests__/ManifestMiddleware-test.ts
@@ -258,7 +258,7 @@ describe('_resolveProjectSettingsAsync', () => {
const settings = await middleware._resolveProjectSettingsAsync({
hostname: 'localhost',
platform: 'android',
- forwarded: { authority: 'proxy.test:4443', protocol: 'https' },
+ forwarded: { authority: 'proxy.test:4443', protocol: 'https', viaForwardedHeader: true },
} as any);
expect(settings.bundleUrl).toBe(
@@ -268,6 +268,34 @@ describe('_resolveProjectSettingsAsync', () => {
const resolver = jest.mocked(resolveManifestAssets).mock.calls?.[0]?.[1].resolver;
await expect(resolver?.('./assets/icon.png')).resolves.toBe('assets/assets/icon.png');
});
+ it(`returns absolute bundle and asset URLs when only a proxy added forwarding headers`, async () => {
+ const middleware = new MockManifestMiddleware('/', {
+ constructUrl: jest.fn(() => 'http://fake.mock'),
+ mode: 'development',
+ });
+
+ jest.mocked(getConfig).mockClear();
+ jest.mocked(resolveManifestAssets).mockClear();
+
+ middleware._getBundleUrl = jest.fn(
+ () => 'http://fake.mock/index.bundle?platform=android&dev=true'
+ );
+
+ const settings = await middleware._resolveProjectSettingsAsync({
+ hostname: 'localhost',
+ platform: 'android',
+ forwarded: { authority: 'proxy.test:4443', protocol: 'https', viaForwardedHeader: false },
+ } as any);
+
+ // The client didn't report the authority itself, so it may not resolve relative URLs.
+ expect(settings.bundleUrl).toBe('http://fake.mock/index.bundle?platform=android&dev=true');
+ expect(settings.hostUri).toBe('proxy.test:4443');
+
+ const resolver = jest.mocked(resolveManifestAssets).mock.calls?.[0]?.[1].resolver;
+ await expect(resolver?.('./assets/icon.png')).resolves.toBe(
+ 'http://fake.mock/assets/assets/icon.png'
+ );
+ });
it(`returns the forwarded authority as hostUri and debuggerHost`, async () => {
const constructUrl = jest.fn(() => 'http://fake.mock');
const middleware = new MockManifestMiddleware('/', { constructUrl, mode: 'development' });
@@ -295,7 +323,7 @@ describe('_resolveProjectSettingsAsync', () => {
hostname: 'localhost',
platform: 'android',
protocol: 'https',
- forwarded: { authority: undefined, protocol: 'https' },
+ forwarded: { authority: undefined, protocol: 'https', viaForwardedHeader: false },
});
expect(settings.hostUri).toBe('fake.mock:8081');
@@ -315,7 +343,7 @@ describe('_resolveProjectSettingsAsync', () => {
await middleware._resolveProjectSettingsAsync({
hostname: 'localhost',
platform: 'android',
- forwarded: { authority: 'proxy.test' },
+ forwarded: { authority: 'proxy.test', viaForwardedHeader: true },
} as any);
const resolver = jest.mocked(resolveManifestAssets).mock.calls?.[0]?.[1].resolver;
diff --git a/packages/@expo/cli/src/start/server/middleware/__tests__/RuntimeRedirectMiddleware-test.ts b/packages/@expo/cli/src/start/server/middleware/__tests__/RuntimeRedirectMiddleware-test.ts
index cc5263fdffffb0..df084a630d449a 100644
--- a/packages/@expo/cli/src/start/server/middleware/__tests__/RuntimeRedirectMiddleware-test.ts
+++ b/packages/@expo/cli/src/start/server/middleware/__tests__/RuntimeRedirectMiddleware-test.ts
@@ -114,7 +114,7 @@ describe('handleRequestAsync', () => {
);
expect(getLocation).toHaveBeenCalledWith({
runtime: 'expo',
- forwarded: { authority: 'proxy.test:4443', protocol: 'https' },
+ forwarded: { authority: 'proxy.test:4443', protocol: 'https', viaForwardedHeader: true },
});
});
});
diff --git a/packages/@expo/cli/src/start/server/middleware/__tests__/resolveForwarded-test.ts b/packages/@expo/cli/src/start/server/middleware/__tests__/resolveForwarded-test.ts
index c3f48b68830e4d..5cd68fd07cb7ae 100644
--- a/packages/@expo/cli/src/start/server/middleware/__tests__/resolveForwarded-test.ts
+++ b/packages/@expo/cli/src/start/server/middleware/__tests__/resolveForwarded-test.ts
@@ -11,13 +11,13 @@ describe(parseForwardedRequestInfo, () => {
it(`parses the RFC 7239 "Forwarded" header`, () => {
expect(
parseForwardedRequestInfo(asReq({ forwarded: 'host="proxy.test:4443";proto=https' }))
- ).toEqual({ authority: 'proxy.test:4443', protocol: 'https' });
+ ).toEqual({ authority: 'proxy.test:4443', protocol: 'https', viaForwardedHeader: true });
});
it(`parses unquoted and reordered "Forwarded" parameters`, () => {
expect(
parseForwardedRequestInfo(asReq({ forwarded: 'for=192.0.2.1;proto=http;host=proxy.test' }))
- ).toEqual({ authority: 'proxy.test', protocol: 'http' });
+ ).toEqual({ authority: 'proxy.test', protocol: 'http', viaForwardedHeader: true });
});
it(`uses the first element when a proxy chain appended to "Forwarded"`, () => {
@@ -25,7 +25,7 @@ describe(parseForwardedRequestInfo, () => {
parseForwardedRequestInfo(
asReq({ forwarded: 'host="proxy.test:4443";proto=https, host=inner.test;proto=http' })
)
- ).toEqual({ authority: 'proxy.test:4443', protocol: 'https' });
+ ).toEqual({ authority: 'proxy.test:4443', protocol: 'https', viaForwardedHeader: true });
});
it(`falls back to the "X-Forwarded-*" headers`, () => {
@@ -33,7 +33,7 @@ describe(parseForwardedRequestInfo, () => {
parseForwardedRequestInfo(
asReq({ 'x-forwarded-host': 'proxy.test:4443', 'x-forwarded-proto': 'https' })
)
- ).toEqual({ authority: 'proxy.test:4443', protocol: 'https' });
+ ).toEqual({ authority: 'proxy.test:4443', protocol: 'https', viaForwardedHeader: false });
});
it(`uses the first value of comma-separated "X-Forwarded-*" headers`, () => {
@@ -41,7 +41,7 @@ describe(parseForwardedRequestInfo, () => {
parseForwardedRequestInfo(
asReq({ 'x-forwarded-host': 'proxy.test, inner.test', 'x-forwarded-proto': 'https, http' })
)
- ).toEqual({ authority: 'proxy.test', protocol: 'https' });
+ ).toEqual({ authority: 'proxy.test', protocol: 'https', viaForwardedHeader: false });
});
it(`prefers "Forwarded" over the "X-Forwarded-*" headers`, () => {
@@ -53,25 +53,35 @@ describe(parseForwardedRequestInfo, () => {
'x-forwarded-proto': 'http',
})
)
- ).toEqual({ authority: 'proxy.test', protocol: 'https' });
+ ).toEqual({ authority: 'proxy.test', protocol: 'https', viaForwardedHeader: true });
});
it(`returns the protocol alone when no host was forwarded`, () => {
expect(parseForwardedRequestInfo(asReq({ 'x-forwarded-proto': 'https' }))).toEqual({
protocol: 'https',
+ viaForwardedHeader: false,
});
});
it(`returns the authority alone when no protocol was forwarded`, () => {
expect(parseForwardedRequestInfo(asReq({ 'x-forwarded-host': 'proxy.test:4443' }))).toEqual({
authority: 'proxy.test:4443',
+ viaForwardedHeader: false,
});
});
it(`normalizes the authority and drops anything but host and port`, () => {
expect(
parseForwardedRequestInfo(asReq({ 'x-forwarded-host': 'user@Proxy.TEST:4443/../evil' }))
- ).toEqual({ authority: 'proxy.test:4443' });
+ ).toEqual({ authority: 'proxy.test:4443', viaForwardedHeader: false });
+ });
+
+ it(`treats the authority as proxy-added when only "X-Forwarded-Host" carries it`, () => {
+ expect(
+ parseForwardedRequestInfo(
+ asReq({ forwarded: 'proto=https', 'x-forwarded-host': 'proxy.test:4443' })
+ )
+ ).toEqual({ authority: 'proxy.test:4443', protocol: 'https', viaForwardedHeader: false });
});
it(`ignores unusable values`, () => {
diff --git a/packages/@expo/cli/src/start/server/middleware/resolveForwarded.ts b/packages/@expo/cli/src/start/server/middleware/resolveForwarded.ts
index 47d263aeae0d13..452e49a0a3772a 100644
--- a/packages/@expo/cli/src/start/server/middleware/resolveForwarded.ts
+++ b/packages/@expo/cli/src/start/server/middleware/resolveForwarded.ts
@@ -4,6 +4,7 @@ import type { ServerRequest } from './server.types';
export interface ForwardedRequestInfo {
authority: string | undefined;
protocol: 'http' | 'https' | undefined;
+ viaForwardedHeader: boolean;
}
function splitOutsideQuotes(value: string, separator: string): string[] {
@@ -85,11 +86,13 @@ export function parseForwardedRequestInfo(req: ServerRequest): ForwardedRequestI
const headers = req.headers ?? {};
const forwardedStr = firstHeaderValue(headers['forwarded']);
const forwarded = forwardedStr ? parseForwardedHeader(forwardedStr) : null;
- const authority = coerceAuthority(
- forwarded?.host ?? firstHeaderValue(headers['x-forwarded-host'])
- );
+ const forwardedAuthority = coerceAuthority(forwarded?.host);
+ const authority =
+ forwardedAuthority ?? coerceAuthority(firstHeaderValue(headers['x-forwarded-host']));
const protocol = coerceProtocol(
forwarded?.proto ?? firstHeaderValue(headers['x-forwarded-proto'])
);
- return authority || protocol ? { authority, protocol } : null;
+ return authority || protocol
+ ? { authority, protocol, viaForwardedHeader: forwardedAuthority != null }
+ : null;
}
diff --git a/packages/@expo/fingerprint/CHANGELOG.md b/packages/@expo/fingerprint/CHANGELOG.md
index f48ef9649004a3..1f4e4709984917 100644
--- a/packages/@expo/fingerprint/CHANGELOG.md
+++ b/packages/@expo/fingerprint/CHANGELOG.md
@@ -19,6 +19,7 @@
### 🐛 Bug fixes
- Set development mode before loading Expo config and `.env` files. ([#48839](https://github.com/expo/expo/pull/48839) by [@ramonclaudio](https://github.com/ramonclaudio))
+- Fixed ignore patterns (built-in and `.fingerprintignore`) not matching on Windows, which made fingerprints differ between Windows machines and EAS builds ("Runtime version mismatch"). ([#46816](https://github.com/expo/expo/pull/46816) by [@blurbyte](https://github.com/blurbyte))
### 💡 Others
diff --git a/packages/@expo/fingerprint/src/ProjectWorkflow.ts b/packages/@expo/fingerprint/src/ProjectWorkflow.ts
index 4c64cf7939cf20..048227d8602795 100644
--- a/packages/@expo/fingerprint/src/ProjectWorkflow.ts
+++ b/packages/@expo/fingerprint/src/ProjectWorkflow.ts
@@ -8,7 +8,7 @@ import path from 'path';
import { resolveExpoConfigPluginsPackagePath } from './ExpoResolver';
import type { Platform, ProjectWorkflow } from './Fingerprint.types';
-import { isIgnoredPathWithMatchObjects, pathExistsAsync } from './utils/Path';
+import { isIgnoredPathWithMatchObjects, pathExistsAsync, toPosixPath } from './utils/Path';
/**
* Replicated project workflow detection logic from expo-updates:
@@ -43,7 +43,7 @@ export async function resolveProjectWorkflowAsync(
const vcsClient = await getVCSClientAsync(projectRoot);
const vcsRoot = path.normalize(await vcsClient.getRootPathAsync());
for (const marker of platformWorkflowMarkers) {
- const relativeMarker = path.relative(vcsRoot, marker);
+ const relativeMarker = toPosixPath(path.relative(vcsRoot, marker));
if (
(await pathExistsAsync(marker)) &&
!isIgnoredPathWithMatchObjects(relativeMarker, fingerprintIgnorePaths) &&
diff --git a/packages/@expo/metro-config/package.json b/packages/@expo/metro-config/package.json
index 6dd78048b7aa2e..9225f567395504 100644
--- a/packages/@expo/metro-config/package.json
+++ b/packages/@expo/metro-config/package.json
@@ -93,7 +93,7 @@
"hermes-parser": "^0.36.0",
"jsc-safe-url": "^0.2.4",
"lightningcss": "^1.30.1",
- "noxcturnal": "0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc",
+ "noxcturnal": "^0.1.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.23",
"resolve-from": "^5.0.0"
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 2e83e167460018..d84cde1ee5a170 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
@@ -4,7 +4,9 @@ import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping';
import { createRequire } from 'node:module';
import path from 'node:path';
import {
+ defineNativePlugin,
defineNativePipeline,
+ defineVisitor,
NativeTransformError,
TransformSyntaxError,
transform as transformWithNoxcturnal,
@@ -20,6 +22,7 @@ import {
transformNodeModuleWithNoxcturnal,
transformNodeModuleWithNoxcturnalSync,
} from '../../noxcturnal/noxcturnal-transformer';
+import { createExpoRouterServerExportsPlugin } from '../../noxcturnal/plugins/expo-router-server-exports';
function options(overrides: Partial = {}): JsTransformOptions {
return {
@@ -434,6 +437,92 @@ it('removes loader and metadata exports from a native client route', async () =>
});
});
+it.each([
+ ['sibling source', '/app/src/route.js', 'app'],
+ ['dot-dot-prefixed sibling', '/app/app-other/route.js', 'app'],
+ ['relative parent escape', '/app/route.js', 'app/nested'],
+ ['absolute custom root', '/app/custom/route.js', '/app/other'],
+] as const)(
+ 'keeps Router server exports outside the configured root for %s',
+ (_name, candidate, routerRoot) => {
+ const source = `export default function Route() {}
+ export async function loader() { return null; }
+ export const generateMetadata = () => ({}), keep = 1;`;
+ const input = {
+ filename: candidate,
+ projectRoot: '/app',
+ source,
+ options: options({
+ customTransformOptions: { engine: 'hermes', routerRoot, isLoaderBundle: 'true' },
+ }),
+ isDefaultExpoTransformer: true,
+ };
+ const result = transformWithNoxcturnal(
+ source,
+ candidate,
+ defineNativePipeline({
+ phases: [
+ {
+ name: 'router-server-exports',
+ plugins: [
+ createExpoRouterServerExportsPlugin({ defineNativePlugin, defineVisitor } as any),
+ ],
+ },
+ ],
+ }),
+ { pluginData: { input } }
+ );
+
+ expect(result.status).toBe('complete');
+ if (result.status !== 'complete') return;
+ expect(result.code).toBe(source);
+ expect(result.metadata.loaderReference).toBeUndefined();
+ expect(result.metadata.performConstantFolding).toBeUndefined();
+ }
+);
+
+it.each([
+ ['relative custom root', '/app/routes/route.js', 'routes'],
+ ['decoded absolute custom root', '/app/custom root/route.js', '/app/custom%20root'],
+] as const)(
+ 'applies Router server exports inside the configured root for %s',
+ (_name, candidate, routerRoot) => {
+ const source = `export default function Route() {}
+ export async function loader() { return null; }
+ export function helper() {}`;
+ const input = {
+ filename: candidate,
+ projectRoot: '/app',
+ source,
+ options: options({
+ customTransformOptions: { engine: 'hermes', routerRoot, isLoaderBundle: 'true' },
+ }),
+ isDefaultExpoTransformer: true,
+ };
+ const result = transformWithNoxcturnal(
+ source,
+ candidate,
+ defineNativePipeline({
+ phases: [
+ {
+ name: 'router-server-exports',
+ plugins: [
+ createExpoRouterServerExportsPlugin({ defineNativePlugin, defineVisitor } as any),
+ ],
+ },
+ ],
+ }),
+ { pluginData: { input } }
+ );
+
+ expect(result.status).toBe('complete');
+ if (result.status !== 'complete') return;
+ expect(result.code).toContain('loader');
+ expect(result.code).not.toMatch(/\bRoute\b|\bhelper\b/);
+ expect(result.metadata.loaderReference).toBe(candidate);
+ }
+);
+
it.each(['/app/app/..admin.tsx', '/app/app/..internal/route.ts'])(
'treats the dot-dot-prefixed descendant %s as a native client route',
async (route) => {
@@ -1119,6 +1208,70 @@ it('captures a lexical binding that shadows a same-named Program binding', async
expect(result.result.code).toMatch(/\(\) => \[_?value\]/);
});
+it('composes a nested server action hoist without overlapping its declaration replacement', async () => {
+ const result = await transformFileFullyWithNoxcturnal({
+ filename: '/app/app/index.tsx',
+ projectRoot: '/app',
+ source: `export default function Screen() {
+ const prefix = 'hello';
+ const action = renderNativeViews;
+ return ;
+ async function renderNativeViews(name: string) {
+ "use server";
+ return {prefix + name};
+ }
+ }`,
+ options: options({
+ customTransformOptions: { engine: 'hermes', environment: 'react-server' },
+ }),
+ isDefaultExpoTransformer: true,
+ config: fullConfig(),
+ });
+
+ expect(result.status).toBe('complete');
+ if (result.status !== 'complete') return;
+ expect(result.result.code).not.toContain('"use server"');
+ expect(result.result.code).toMatch(/var \[prefix\] = .+\.value/);
+ expect(result.result.code).toMatch(
+ /var renderNativeViews = _?\$\$INLINE_ACTION\.bind\(null, _?wrapBoundArgs/
+ );
+ expect(result.result.code.indexOf('var renderNativeViews')).toBeLessThan(
+ result.result.code.indexOf('const action = renderNativeViews')
+ );
+});
+
+it('hoists a nested server action declaration without captures', async () => {
+ const result = await transformFileFullyWithNoxcturnal({
+ filename: '/app/app/index.tsx',
+ projectRoot: '/app',
+ source: `export default function Screen() {
+ "use strict";
+ const action = submit;
+ return action;
+ async function submit(value: string) {
+ "use server";
+ return value;
+ }
+ }`,
+ options: options({
+ customTransformOptions: { engine: 'hermes', environment: 'react-server' },
+ }),
+ isDefaultExpoTransformer: true,
+ config: fullConfig(),
+ });
+
+ expect(result.status).toBe('complete');
+ if (result.status !== 'complete') return;
+ expect(result.result.code).toMatch(/var submit = _?\$\$INLINE_ACTION;/);
+ expect(result.result.code).not.toContain('.bind(null');
+ expect(result.result.code.indexOf('"use strict"')).toBeLessThan(
+ result.result.code.indexOf('var submit')
+ );
+ expect(result.result.code.indexOf('var submit')).toBeLessThan(
+ result.result.code.indexOf('const action = submit')
+ );
+});
+
it("matches Babel's module-level React Server action registrations", async () => {
const candidate = '/app/actions/server.ts';
const source = `"use server";
diff --git a/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/language-plugins.test.ts b/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/language-plugins.test.ts
index 86b73be81ce07e..8f6417ef42332b 100644
--- a/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/language-plugins.test.ts
+++ b/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/language-plugins.test.ts
@@ -1306,6 +1306,11 @@ it.each([
'module.exports = async (...rest) => rest.length',
/\(\.\.\.rest\)\s*=>\s*\(async\s*\(\)\s*=>\s*\{\s*return rest\.length;/,
],
+ [
+ 'rest parameter with block body',
+ 'module.exports = async (...rest) => { return rest.length; }',
+ /\(\.\.\.rest\)\s*=>\s*\(async\s*\(\)\s*=>\s*\{\s*return rest\.length;/,
+ ],
])('applies the maintained async-arrow workaround for %s', async (_name, source, expected) => {
const result = await transformNodeModuleWithNoxcturnal({
filename,
@@ -1359,6 +1364,24 @@ it('preserves runtime behavior across maintained async-arrow rewrites', async ()
await expect((module.exports as () => Promise)()).resolves.toEqual([1, 7, 'a:b', 1]);
});
+it('preserves source mappings for a composite rest-parameter async-arrow rewrite', async () => {
+ const source = `module.exports = async (...rest) => rest.length;`;
+ const result = await transformNodeModuleWithNoxcturnal({
+ filename,
+ projectRoot: '/app',
+ source,
+ options: options(),
+ isDefaultExpoTransformer: true,
+ });
+ if (result.status === 'fallback') throw new Error(result.reason);
+ const original = originalPositionFor(
+ new TraceMap({ version: 3, sources: [filename], ...result.result.map } as any),
+ generatedPositionOf(result.result.code, 'rest.length')
+ );
+
+ expect(original).toMatchObject({ line: 1, column: source.indexOf('rest.length') });
+});
+
it.each([
[
'declaration',
diff --git a/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/metro-modules.test.ts b/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/metro-modules.test.ts
index 224ddfcad33ad1..35bfebd233431e 100644
--- a/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/metro-modules.test.ts
+++ b/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/metro-modules.test.ts
@@ -929,6 +929,38 @@ it('compacts the complete Metro output without falling back to Babel', async ()
expect(result.dependencies.map(({ name }) => name)).toEqual(['one']);
});
+it('avoids compact Metro pseudo-global collisions with module bindings', async () => {
+ const result = await transformFileFullyWithNoxcturnal({
+ filename,
+ projectRoot: '/app',
+ source: `import value from "one";
+ let g = 1, r = 2, i = 3, a = 4, m = 5, e = 6, d = 7;
+ export default [value, g, r, i, a, m, e, d];`,
+ options: options({ dev: false, minify: true }),
+ isDefaultExpoTransformer: true,
+ config: { ...fullConfig(), unstable_compactOutput: true },
+ });
+
+ expect(result.status).toBe('complete');
+ if (result.status !== 'complete') return;
+ expect(result.result.code).toMatch(/__d\(function\(_g,_r,_i,_a,_m,_e,_dependencyMap\)\{/);
+ let factory: Function | undefined;
+ new Function('__d', result.result.code)((value: Function) => {
+ factory = value;
+ });
+ const moduleExports: { default?: unknown } = {};
+ factory?.(
+ globalThis,
+ () => 'required',
+ () => 'imported',
+ () => ({ default: 'imported' }),
+ { exports: moduleExports },
+ moduleExports,
+ [0]
+ );
+ expect(moduleExports.default).toEqual(['required', 1, 2, 3, 4, 5, 6, 7]);
+});
+
it('completes production constant folding and DCE in native code', async () => {
const result = await transformFileFullyWithNoxcturnal({
filename,
@@ -2174,6 +2206,15 @@ it.each([
it.each([
['class', `export default class DOMException {}; DOMException.code = 1;`],
+ [
+ 'lowered class',
+ `export default class DOMException extends Error {
+ #name;
+ constructor(message) { super(message); this.#name = 'Error'; }
+ get name() { return this.#name; }
+ }
+ DOMException.code = 1;`,
+ ],
['function', `export default function createValue() {}; createValue.code = 1;`],
])('preserves a named default %s declaration binding', async (_name, source) => {
const result = await transformFileFullyWithNoxcturnal({
diff --git a/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/react-server-plugins.test.ts b/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/react-server-plugins.test.ts
index 2b6763a4036d58..b71b9383004307 100644
--- a/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/react-server-plugins.test.ts
+++ b/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/react-server-plugins.test.ts
@@ -567,6 +567,52 @@ it.each(['use client', 'use dom'])(
}
);
+it('excludes type-only and export-all declarations from client proxy exports', async () => {
+ const result = await transformFileFullyWithNoxcturnal({
+ filename: '/app/src/client.ts',
+ projectRoot: '/app',
+ source: `'use client';
+ export interface Props { value: string }
+ export type Value = string;
+ export type { External } from './types';
+ const mixed = 2;
+ export { type Value as MixedType, mixed };
+ export * from './other';
+ export const value = 1;`,
+ options: options({
+ customTransformOptions: { engine: 'hermes', environment: 'react-server' },
+ }),
+ isDefaultExpoTransformer: true,
+ config: fullConfig(),
+ });
+
+ expect(result.status).toBe('complete');
+ if (result.status !== 'complete') return;
+ expect(result.result.metadata.proxyExports).toEqual(['mixed', 'value']);
+ expect(result.result.code).not.toContain('registerClientReference(proxy, "*"');
+});
+
+it('excludes Flow type declarations from client proxy exports', async () => {
+ const result = await transformFileFullyWithNoxcturnal({
+ filename: '/app/src/client.js',
+ projectRoot: '/app',
+ source: `// @flow
+ 'use client';
+ export interface Props { value: string }
+ export type Value = string;
+ export const value = 1;`,
+ options: options({
+ customTransformOptions: { engine: 'hermes', environment: 'react-server' },
+ }),
+ isDefaultExpoTransformer: true,
+ config: fullConfig(),
+ });
+
+ expect(result.status).toBe('complete');
+ if (result.status !== 'complete') return;
+ expect(result.result.metadata.proxyExports).toEqual(['value']);
+});
+
it("keeps conflicting React Server directives on Babel's diagnostic path", async () => {
const result = await transformFileFullyWithNoxcturnal({
filename: '/app/src/conflict.js',
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 975dfb5baa3011..79d64f30cf8166 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
@@ -83,11 +83,18 @@ export function sortedUniqueCaptureNames(captures: readonly NodeView[]): string[
return [...new Set(captures.map((capture) => String(capture.name)))].sort();
}
+export interface MetroPseudoGlobals {
+ global: string;
+ module: string;
+ exports: string;
+}
+
export interface MetroTransformPluginData extends ExpoTransformPluginData {
input: NoxcturnalMetroTransformInput;
shared: MetroDependencyShared;
collectOnly: boolean;
normalizePseudoGlobals: boolean;
+ pseudoGlobals: MetroPseudoGlobals;
}
export function expoPluginInput(context: { pluginData: unknown }): NoxcturnalTransformInput {
@@ -140,6 +147,11 @@ function createMetroTransformPluginData(
input.config.unstable_disableModuleWrapping !== true &&
input.source.length <= (input.config.optimizationSizeLimit ?? Number.POSITIVE_INFINITY) &&
!sourceFacts.hasPseudoGlobals,
+ pseudoGlobals: {
+ global: 'g',
+ module: 'm',
+ exports: 'e',
+ },
};
}
diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/expo-router-server-exports.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/expo-router-server-exports.ts
index ba08c262557f3f..fd8e916401b62c 100644
--- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/expo-router-server-exports.ts
+++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/expo-router-server-exports.ts
@@ -3,12 +3,14 @@ import path from 'path';
import {
expoPluginInput,
+ isPathInsideRoot,
type Noxcturnal,
type NoxcturnalTransformInput,
} from '../noxcturnal-transformer';
interface ExpoRouterExportState {
input: NoxcturnalTransformInput;
+ enabled: boolean;
isLoaderBundle: boolean;
isServer: boolean;
loaderReference?: string;
@@ -26,8 +28,14 @@ export function createExpoRouterServerExportsPlugin(
},
createState: (context) => {
const input = expoPluginInput(context);
+ const routerRootOption = input.options.customTransformOptions?.routerRoot;
+ const routerRoot = typeof routerRootOption === 'string' ? decodeURI(routerRootOption) : 'app';
+ const absoluteRouterRoot = path.isAbsolute(routerRoot)
+ ? routerRoot
+ : path.join(input.projectRoot, routerRoot);
return {
input,
+ enabled: isPathInsideRoot(absoluteRouterRoot, input.filename),
isLoaderBundle: String(input.options.customTransformOptions?.isLoaderBundle) === 'true',
isServer: input.options.customTransformOptions?.environment === 'node',
performConstantFolding: false,
@@ -35,6 +43,7 @@ export function createExpoRouterServerExportsPlugin(
},
visitors: [
nox.defineVisitor('ExportDefaultDeclaration', {}, (exportPath, state) => {
+ if (!state.enabled) return;
if (!state.isLoaderBundle) return;
exportPath.remove();
state.performConstantFolding = true;
@@ -63,6 +72,7 @@ export function createExpoRouterServerExportsPlugin(
},
},
(exportPath, state) => {
+ if (!state.enabled) return;
if (
exportPath.node.source ||
(exportPath.node.exportKind && exportPath.node.exportKind !== 'value') ||
@@ -130,6 +140,7 @@ export function createExpoRouterServerExportsPlugin(
),
],
post(context, state) {
+ if (!state.enabled) return;
if (state.loaderReference) {
context.metadata.set('loaderReference', state.loaderReference);
}
diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/fix-hermes-v1-async-arrow-non-simple-params.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/fix-hermes-v1-async-arrow-non-simple-params.ts
index 2282ecfba84aa3..86edc0424d1e52 100644
--- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/fix-hermes-v1-async-arrow-non-simple-params.ts
+++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/fix-hermes-v1-async-arrow-non-simple-params.ts
@@ -42,10 +42,6 @@ export function createFixHermesV1AsyncArrowNonSimpleParamsPlugin(
const body = arrow.getChild('body');
if (!body) arrow.unsupported('missing-async-arrow-body');
const bodySource = body!.getSource();
- const blockBody = arrow.node.expression
- ? arrow.context.code.template`{ return ${bodySource}; }`
- : bodySource;
-
// Hermes rejects every rest-parameter async arrow. Keep its original
// parameter binding in a synchronous closure and invoke a zero-argument
// async arrow inside it, matching Expo's maintained Babel transform.
@@ -56,8 +52,18 @@ export function createFixHermesV1AsyncArrowNonSimpleParamsPlugin(
parameter.node.type === 'BindingRestElement'
)
) {
- arrow.context.editor.remove(arrow.getSource().start, arrow.getSource().start + 5);
- body!.replaceWith(arrow.context.code.template`(async () => ${blockBody})()`);
+ const arrowSource = arrow.getSource();
+ arrow.replaceWith({
+ kind: 'composite',
+ parts: [
+ arrow.context.sourceSlice(arrowSource.start + 5, bodySource.start),
+ '(async () => ',
+ ...(arrow.node.expression
+ ? (['{ return ', bodySource, '; }'] as const)
+ : ([bodySource] as const)),
+ ')()',
+ ],
+ });
return;
}
diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-dependency.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-dependency.ts
index 1d5d207aacc24f..0aaf13f0295527 100644
--- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-dependency.ts
+++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-dependency.ts
@@ -297,9 +297,15 @@ export function createMetroDependencyPlugin(
enter(program, state: MetroDependencyState) {
const { input, normalizePseudoGlobals } = metroPluginData(program.context);
if (normalizePseudoGlobals) {
- state.requireName = 'r';
- state.importDefaultName = 'i';
- state.importAllName = 'a';
+ state.requireName = program.scope.hasBinding('r')
+ ? program.scope.generateUid('r')
+ : 'r';
+ state.importDefaultName = program.scope.hasBinding('i')
+ ? program.scope.generateUid('i')
+ : 'i';
+ state.importAllName = program.scope.hasBinding('a')
+ ? program.scope.generateUid('a')
+ : 'a';
} else if (input.config.unstable_disableModuleWrapping !== true) {
state.requireName =
input.config.unstable_renameRequire === false
@@ -819,13 +825,14 @@ export function createMetroDependencyPlugin(
context.metadata.set('metroImportDefaultName', state.importDefaultName);
context.metadata.set('metroImportAllName', state.importAllName);
if (!collectOnly && input.config.unstable_disableModuleWrapping !== true) {
+ const pseudoGlobals = metroPluginData(context).pseudoGlobals;
const parameters = [
- normalizePseudoGlobals ? 'g' : 'global',
+ normalizePseudoGlobals ? pseudoGlobals.global : 'global',
state.requireName,
state.importDefaultName,
state.importAllName,
- normalizePseudoGlobals ? 'm' : 'module',
- normalizePseudoGlobals ? 'e' : 'exports',
+ normalizePseudoGlobals ? pseudoGlobals.module : 'module',
+ normalizePseudoGlobals ? pseudoGlobals.exports : 'exports',
state.dependencyMapName,
];
context.editor.prepend(
diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-esm-globals.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-esm-globals.ts
index d0d7b30b60f571..d8698727010d12 100644
--- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-esm-globals.ts
+++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-esm-globals.ts
@@ -1,17 +1,40 @@
import type { DefinedNativePlugin } from 'noxcturnal';
-import { metroPluginData, type Noxcturnal } from '../noxcturnal-transformer';
+import {
+ metroPluginData,
+ type MetroPseudoGlobals,
+ type Noxcturnal,
+} from '../noxcturnal-transformer';
-export function createMetroEsmGlobalsPlugin(nox: Noxcturnal): DefinedNativePlugin {
- return nox.defineNativePlugin({
+interface MetroEsmGlobalsState {
+ names: MetroPseudoGlobals;
+}
+
+function pseudoGlobalName(
+ scope: { hasBinding(name: string): boolean; generateUid(name: string): string },
+ preferred: string
+): string {
+ return scope.hasBinding(preferred) ? scope.generateUid(preferred) : preferred;
+}
+
+export function createMetroEsmGlobalsPlugin(
+ nox: Noxcturnal
+): DefinedNativePlugin {
+ return nox.defineNativePlugin({
name: 'metro-native-esm-pseudo-global-renames',
+ createState: () => ({ names: { global: 'g', module: 'm', exports: 'e' } }),
visitors: [
- nox.defineVisitor('Program', { scope: true }, (program) => {
+ nox.defineVisitor('Program', { scope: true }, (program, state) => {
for (const name of ['global', 'require', 'module', 'exports']) {
if (program.scope.hasBinding(name)) {
program.scope.rename(name, program.scope.generateUid(name));
}
}
+ if (!metroPluginData(program.context).normalizePseudoGlobals) return;
+ state.names.global = pseudoGlobalName(program.scope, 'g');
+ state.names.module = pseudoGlobalName(program.scope, 'm');
+ state.names.exports = pseudoGlobalName(program.scope, 'e');
+ metroPluginData(program.context).pseudoGlobals = state.names;
}),
nox.defineVisitor(
'Identifier',
@@ -19,16 +42,14 @@ export function createMetroEsmGlobalsPlugin(nox: Noxcturnal): DefinedNativePlugi
fields: ['name', 'global'],
where: { name: { oneOf: ['global', 'module', 'exports'] } },
},
- (identifier) => {
+ (identifier, state) => {
if (
identifier.node.global !== true ||
!metroPluginData(identifier.context).normalizePseudoGlobals
) {
return;
}
- const replacement = { global: 'g', module: 'm', exports: 'e' }[
- String(identifier.node.name)
- ];
+ const replacement = state.names[String(identifier.node.name) as keyof typeof state.names];
if (replacement) identifier.replaceWith(replacement);
}
),
diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-live-bindings.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-live-bindings.ts
index 1e10362481e253..df1e99374760b1 100644
--- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-live-bindings.ts
+++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/metro-live-bindings.ts
@@ -21,8 +21,10 @@ export function createMetroLiveBindingsPlugin(
return scope.generateUid(name);
};
const property = (object: string, name: string) => `${object}.${name}`;
- const exportsName = (context: { pluginData: unknown }) =>
- metroPluginData(context).normalizePseudoGlobals ? 'e' : 'exports';
+ const exportsName = (context: { pluginData: unknown }) => {
+ const { normalizePseudoGlobals, pseudoGlobals } = metroPluginData(context);
+ return normalizePseudoGlobals ? pseudoGlobals.exports : 'exports';
+ };
const liveExport = (name: string, expression: string, target = 'exports') =>
`Object.defineProperty(${target}, ${JSON.stringify(name)}, { enumerable: true, get: function () { return ${expression}; } });`;
const exportValue = (name: string, expression: string, target = 'exports') =>
diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/react-server-client-proxy.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/react-server-client-proxy.ts
index 15144db5f5121e..fada6c18410490 100644
--- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/react-server-client-proxy.ts
+++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/react-server-client-proxy.ts
@@ -75,20 +75,11 @@ export function createReactServerClientProxyPlugin(
}
}
),
- nox.defineVisitor(
- 'ExportAllDeclaration',
- { ancestry: { mode: 'programParent' } },
- (exportPath, state) => {
- if (state.enabled && exportPath.parentPath?.node.type === 'Program') {
- state.exports.add('*');
- }
- }
- ),
nox.defineVisitor(
'ExportNamedDeclaration',
{
+ fields: ['exportKind'],
ancestry: { mode: 'programParent' },
- scope: true,
children: {
declaration: {
route: 'Declaration',
@@ -104,13 +95,22 @@ export function createReactServerClientProxyPlugin(
},
specifiers: {
route: 'ExportSpecifier',
- fields: ['exported'],
+ fields: ['exported', 'exportKind'],
},
} as any,
},
(exportPath, state) => {
if (!state.enabled || exportPath.parentPath?.node.type !== 'Program') return;
+ if (exportPath.node.exportKind === 'type') return;
const declaration = exportPath.getChild('declaration') as any;
+ if (
+ declaration?.node.type === 'TSInterfaceDeclaration' ||
+ declaration?.node.type === 'TSTypeAliasDeclaration' ||
+ declaration?.node.type === 'TypeAlias' ||
+ declaration?.node.type === 'InterfaceDeclaration'
+ ) {
+ return;
+ }
if (typeof declaration?.node.name === 'string') {
state.exports.add(declaration.node.name);
} else if (declaration?.node.type === 'VariableDeclaration') {
@@ -122,6 +122,7 @@ export function createReactServerClientProxyPlugin(
}
}
for (const specifier of exportPath.getChildList('specifiers')) {
+ if (specifier.node.exportKind === 'type') continue;
state.exports.add(String(specifier.node.exported));
}
}
diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/react-server-module-actions.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/react-server-module-actions.ts
index c4b4f67f7e9bde..895fa894c755fd 100644
--- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/react-server-module-actions.ts
+++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/plugins/react-server-module-actions.ts
@@ -19,9 +19,14 @@ interface ReactServerActionsState {
registerBinding: string;
wrapperBinding: string;
usesCapturedArgs: boolean;
+ hoists: Code[];
actions: { localName?: string; exportedName: string }[];
}
+function emitHoist(state: ReactServerActionsState, code: Code): void {
+ state.hoists.push(code);
+}
+
export function createReactServerModuleActionsPlugin(
nox: Noxcturnal
): DefinedNativePlugin {
@@ -41,6 +46,7 @@ export function createReactServerModuleActionsPlugin(
registerBinding: '',
wrapperBinding: '',
usesCapturedArgs: false,
+ hoists: [],
actions: [],
};
},
@@ -116,12 +122,10 @@ export function createReactServerModuleActionsPlugin(
const closureInit = closureParameter
? `var [${capturedNames.join(', ')}] = ${closureParameter}.value;`
: '';
- arrow
- .getProgramParent()!
- .unshiftContainer(
- 'body',
- `export var ${actionId} = ${state.registerBinding}(async (${extractedParameters}) => {${closureInit}${bodySource}}, ${JSON.stringify(state.moduleId)}, ${JSON.stringify(actionId)});`
- );
+ emitHoist(
+ state,
+ `export var ${actionId} = ${state.registerBinding}(async (${extractedParameters}) => {${closureInit}${bodySource}}, ${JSON.stringify(state.moduleId)}, ${JSON.stringify(actionId)});`
+ );
arrow.replaceWith(
closureParameter
? `${actionId}.bind(null, ${state.wrapperBinding}(() => [${capturedNames.join(', ')}]))`
@@ -181,12 +185,10 @@ export function createReactServerModuleActionsPlugin(
const closureInit = closureParameter
? `var [${capturedNames.join(', ')}] = ${closureParameter}.value;`
: '';
- fn.scope
- .getProgramParent()
- .path!.unshiftContainer(
- 'body',
- `export var ${actionId} = ${state.registerBinding}(async function${name}(${extractedParameters}) {${closureInit}${bodySource}}, ${JSON.stringify(state.moduleId)}, ${JSON.stringify(actionId)});`
- );
+ emitHoist(
+ state,
+ `export var ${actionId} = ${state.registerBinding}(async function${name}(${extractedParameters}) {${closureInit}${bodySource}}, ${JSON.stringify(state.moduleId)}, ${JSON.stringify(actionId)});`
+ );
fn.replaceWith(
closureParameter
? `${actionId}.bind(null, ${state.wrapperBinding}(() => [${capturedNames.join(', ')}]))`
@@ -202,7 +204,10 @@ export function createReactServerModuleActionsPlugin(
{
fields: ['async', 'generator', 'name', 'bodyEnd'],
scope: true,
- ancestry: { mode: 2 },
+ ancestry: {
+ mode: 2,
+ routes: { Function: { fields: ['bodyStart'] } },
+ },
children: {
body: {
route: 'FunctionBody',
@@ -226,29 +231,53 @@ export function createReactServerModuleActionsPlugin(
if (fn.node.async !== true || fn.node.generator === true) {
fn.unsupported('server-action-non-async-inline');
}
+ if (typeof fn.node.name !== 'string') {
+ fn.unsupported('server-action-nested-function-declaration');
+ }
+ const declarationName = String(fn.node.name);
const topLevel =
fn.parentPath?.node.type === 'Program' ||
(fn.parentPath?.node.type === 'ExportNamedDeclaration' &&
fn.parentPath.parentPath?.node.type === 'Program');
- if (!topLevel || typeof fn.node.name !== 'string') {
- fn.unsupported('server-action-nested-function-declaration');
- }
+ const captures = topLevel
+ ? []
+ : (fn.context.query({
+ captures: [{ functionId: fn.node.id, programBindings: false }],
+ }).captures[fn.node.id] ?? []);
+ const capturedNames = sortedUniqueCaptureNames(captures);
const actionId = fn.scope.getProgramParent().generateUid('$$INLINE_ACTION');
const parameters = fn
.getChildList('params')
.map((parameter) => parameter.sourceText())
.join(', ');
const bodySource = fn.context.source.slice(directive.node.end, Number(fn.node.bodyEnd));
- fn.scope
- .getProgramParent()
- .path!.unshiftContainer(
- 'body',
- `export var ${actionId} = ${state.registerBinding}(async function ${fn.node.name}(${parameters}) {${bodySource}}, ${JSON.stringify(state.moduleId)}, ${JSON.stringify(actionId)});`
- );
- fn.replaceWith(`var ${fn.node.name} = ${actionId};`);
+ const closureParameter =
+ capturedNames.length === 0 ? '' : fn.scope.generateUid('$$CLOSURE');
+ const extractedParameters = [...(closureParameter ? [closureParameter] : []), parameters]
+ .filter(Boolean)
+ .join(', ');
+ const closureInit = closureParameter
+ ? `var [${capturedNames.join(', ')}] = ${closureParameter}.value;`
+ : '';
+ emitHoist(
+ state,
+ `export var ${actionId} = ${state.registerBinding}(async function ${declarationName}(${extractedParameters}) {${closureInit}${bodySource}}, ${JSON.stringify(state.moduleId)}, ${JSON.stringify(actionId)});`
+ );
+ const replacement = `var ${declarationName} = ${
+ closureParameter
+ ? `${actionId}.bind(null, ${state.wrapperBinding}(() => [${capturedNames.join(', ')}]))`
+ : actionId
+ };`;
+ if (topLevel) {
+ fn.replaceWith(replacement);
+ } else {
+ fn.remove();
+ fn.scope.parent!.push(replacement);
+ }
+ if (closureParameter) state.usesCapturedArgs = true;
state.boundary.handledDirectives.add(directive.id);
state.actions.push({
- localName: fn.node.name,
+ localName: declarationName,
exportedName: actionId,
});
}
@@ -419,7 +448,7 @@ export { ${name} as default };`);
state.usesCapturedArgs
? `var ${state.wrapperBinding} = (thunk) => { let cache; return { get value() { return cache || (cache = thunk()); } }; };\n`
: ''
- }`
+ }${state.hoists.length > 0 ? `${state.hoists.join('\n')}\n` : ''}`
);
context.metadata.set('reactServerActions', payload);
context.metadata.set('reactServerReference', pathToFileURL(state.input.filename).href);
diff --git a/packages/expo-app-metrics/CHANGELOG.md b/packages/expo-app-metrics/CHANGELOG.md
index 1635b6246f0b88..0417c259c5664b 100644
--- a/packages/expo-app-metrics/CHANGELOG.md
+++ b/packages/expo-app-metrics/CHANGELOG.md
@@ -13,10 +13,12 @@
### 🐛 Bug fixes
- [android] Fix `UnsupportedOperationException` and `NoSuchMethodError` on Android 7.x ([#48577](https://github.com/expo/expo/pull/48577) by [@Ubax](https://github.com/Ubax))
+- [iOS] Retry the OTA `AppInfo` patch on updates state changes, so a launch where the module registry is created before `expo-updates` has assigned its startup procedure no longer keeps the embedded build's update attribution for the whole session. ([#48899](https://github.com/expo/expo/pull/48899) by [@spsaucier](https://github.com/spsaucier))
- [iOS] Fix a crash on FirebaseAuth's first token refresh. GTMSessionFetcher branches on the class of `session.delegate`, so our network-observing delegate proxy now answers class and protocol checks for the delegate it wraps. ([#48360](https://github.com/expo/expo/pull/48360) by [@tsapeta](https://github.com/tsapeta))
### 💡 Others
+- [Android] Load only requested metric and log rows when preparing observability payloads. ([#49011](https://github.com/expo/expo/pull/49011) by [@Ubax](https://github.com/Ubax))
- Rename the no-update `downloadComplete` state event to `downloadCompleteUnavailable`. ([#47902](https://github.com/expo/expo/pull/47902) by [@kudo](https://github.com/kudo))
- [iOS] Measure the JS bundle load time against the app startup end marker to stay compatible with upcoming React Native versions. ([#47782](https://github.com/expo/expo/pull/47782) by [@tsapeta](https://github.com/tsapeta))
- Add a `caught` source to the private `reportError` for errors reported from user code. ([#47871](https://github.com/expo/expo/pull/47871) by [@tsapeta](https://github.com/tsapeta))
diff --git a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/MetricsDatabase.kt b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/MetricsDatabase.kt
index 4d8b72a4069050..520c31914c2967 100644
--- a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/MetricsDatabase.kt
+++ b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/MetricsDatabase.kt
@@ -125,6 +125,7 @@ data class SessionWithMetrics(
entityColumn = "sessionId"
)
val metrics: List,
+ /** Only populated by relation-backed `SessionDao` queries. */
@Relation(
parentColumn = "id",
entityColumn = "sessionId"
@@ -160,11 +161,7 @@ data class LogRecord(
)
data class SessionWithLogs(
- @Embedded val session: Session,
- @Relation(
- parentColumn = "id",
- entityColumn = "sessionId"
- )
+ val session: Session,
val logs: List
)
@@ -219,6 +216,9 @@ interface MetricDao {
@Delete
suspend fun delete(metrics: List)
+ @Query("SELECT * FROM metrics WHERE metricId IN (:metricIds) ORDER BY timestamp ASC")
+ suspend fun getByIds(metricIds: List): List
+
@Query("SELECT * FROM metrics WHERE sessionId = :sessionId ORDER BY timestamp ASC")
suspend fun getMetricsForSession(sessionId: String): List
}
@@ -231,6 +231,9 @@ interface LogDao {
@Delete
suspend fun delete(logs: List)
+ @Query("SELECT * FROM logs WHERE logId IN (:logIds) ORDER BY timestamp ASC")
+ suspend fun getByIds(logIds: List): List
+
@Query("DELETE FROM logs WHERE timestamp < :cutoffTimestamp")
suspend fun deleteLogsOlderThan(cutoffTimestamp: String)
@@ -269,6 +272,9 @@ interface SessionDao {
@Query("SELECT * FROM sessions WHERE id = :id")
suspend fun getById(id: String): Session?
+ @Query("SELECT * FROM sessions WHERE id IN (:ids)")
+ suspend fun getByIds(ids: List): List
+
// The most recent session other than `:currentSessionId` (null matches all
// rows, so it returns the latest of any).
@Query(
@@ -316,12 +322,4 @@ interface SessionDao {
@Transaction
@Query("SELECT * FROM sessions WHERE id = :id")
suspend fun getSessionWithMetricsBySessionId(id: String): SessionWithMetrics?
-
- @Transaction
- @Query("SELECT DISTINCT s.* FROM sessions s INNER JOIN metrics m ON s.id = m.sessionId WHERE m.metricId IN (:metricIds)")
- suspend fun getSessionsWithMetricsByMetricIds(metricIds: List): List
-
- @Transaction
- @Query("SELECT DISTINCT s.* FROM sessions s INNER JOIN logs l ON s.id = l.sessionId WHERE l.logId IN (:logIds)")
- suspend fun getSessionsWithLogsByLogIds(logIds: List): List
}
diff --git a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt
index 9679712da0bfef..a564059e50cfc7 100644
--- a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt
+++ b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt
@@ -223,53 +223,40 @@ class SessionManager(
}
suspend fun getSessionsWithMetrics(metricIds: List): List {
- val metricIdSet = metricIds.toSet()
- if (metricIds.size <= SQLITE_MAX_BIND_VARIABLES) {
- return database.sessionDao().getSessionsWithMetricsByMetricIds(metricIds).map { sessionWithMetrics ->
- sessionWithMetrics.copy(metrics = sessionWithMetrics.metrics.filter { it.metricId in metricIdSet })
+ val metricsBySessionId = metricIds
+ .distinct()
+ .chunked(SQLITE_MAX_BIND_VARIABLES)
+ .flatMap { database.metricDao().getByIds(it) }
+ .sortedBy { it.timestamp }
+ .groupBy { it.sessionId }
+ val sessionsById = getSessionsByIds(metricsBySessionId.keys).associateBy { it.id }
+
+ return metricsBySessionId.mapNotNull { (sessionId, metrics) ->
+ sessionsById[sessionId]?.let { session ->
+ SessionWithMetrics(session = session, metrics = metrics)
}
}
-
- val allResults = metricIds.chunked(SQLITE_MAX_BIND_VARIABLES).flatMap { chunk ->
- database.sessionDao().getSessionsWithMetricsByMetricIds(chunk)
- }
- return allResults
- .groupBy { it.session.id }
- .map { (_, sessions) ->
- SessionWithMetrics(
- session = sessions.first().session,
- metrics = sessions
- .flatMap { it.metrics }
- .distinctBy { it.metricId }
- .filter { it.metricId in metricIdSet }
- )
- }
}
suspend fun getSessionsWithLogs(logIds: List): List {
- val logIdSet = logIds.toSet()
- if (logIds.size <= SQLITE_MAX_BIND_VARIABLES) {
- return database.sessionDao().getSessionsWithLogsByLogIds(logIds).map { sessionWithLogs ->
- sessionWithLogs.copy(logs = sessionWithLogs.logs.filter { it.logId in logIdSet })
+ val logsBySessionId = logIds
+ .distinct()
+ .chunked(SQLITE_MAX_BIND_VARIABLES)
+ .flatMap { database.logDao().getByIds(it) }
+ .sortedBy { it.timestamp }
+ .groupBy { it.sessionId }
+ val sessionsById = getSessionsByIds(logsBySessionId.keys).associateBy { it.id }
+
+ return logsBySessionId.mapNotNull { (sessionId, logs) ->
+ sessionsById[sessionId]?.let { session ->
+ SessionWithLogs(session = session, logs = logs)
}
}
-
- val allResults = logIds.chunked(SQLITE_MAX_BIND_VARIABLES).flatMap { chunk ->
- database.sessionDao().getSessionsWithLogsByLogIds(chunk)
- }
- return allResults
- .groupBy { it.session.id }
- .map { (_, sessions) ->
- SessionWithLogs(
- session = sessions.first().session,
- logs = sessions
- .flatMap { it.logs }
- .distinctBy { it.logId }
- .filter { it.logId in logIdSet }
- )
- }
}
+ private suspend fun getSessionsByIds(sessionIds: Collection): List =
+ sessionIds.chunked(SQLITE_MAX_BIND_VARIABLES).flatMap { database.sessionDao().getByIds(it) }
+
/**
* Decodes a JSON-encoded `params` / `attributes` column, folds the current
* `GlobalAttributes` snapshot into it, and re-encodes. Returns the original
diff --git a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerTest.kt b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerTest.kt
index 3c744821bed5d5..89c5e1cf796b6f 100644
--- a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerTest.kt
+++ b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerTest.kt
@@ -6,6 +6,7 @@ import androidx.test.core.app.ApplicationProvider
import expo.modules.appmetrics.AppMetadata
import expo.modules.appmetrics.AppUpdatesInfo
import expo.modules.appmetrics.BuildConfig
+import expo.modules.appmetrics.SQLITE_MAX_BIND_VARIABLES
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.*
@@ -392,6 +393,26 @@ class SessionManagerTest {
assertTrue(result[0].metrics.any { it.metricId == "metric-3" })
}
+ @Test
+ fun `getSessionsWithMetrics returns unique metrics in chronological order`() =
+ runTest {
+ val sessionId = "session-1"
+ sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z")
+ database.metricDao().insertAll(
+ listOf(
+ createMetric("metric-z", sessionId, timestamp = "2025-01-01T00:00:02.000Z"),
+ createMetric("metric-a", sessionId, timestamp = "2025-01-01T00:00:01.000Z")
+ )
+ )
+ val metricIds = listOf("metric-z") +
+ (1 until SQLITE_MAX_BIND_VARIABLES).map { "missing-$it" } +
+ listOf("metric-a", "metric-z")
+
+ val result = sessionManager.getSessionsWithMetrics(metricIds)
+
+ assertEquals(listOf("metric-a", "metric-z"), result.single().metrics.map { it.metricId })
+ }
+
@Test
fun `getSessionsWithMetrics returns empty when no metrics match`() =
runTest {
@@ -434,30 +455,7 @@ class SessionManagerTest {
}
@Test
- fun `getSessionsWithMetrics deduplicates sessions spanning multiple chunks`() =
- runTest {
- // Arrange - one session with metrics that will land in different chunks
- val sessionId = "session-1"
- sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z")
-
- // Insert 1100 metrics into the same session
- val allMetricIds = (1..1100).map { "metric-$it" }
- allMetricIds.chunked(500).forEach { chunk ->
- val metrics = chunk.map { createMetric(it, sessionId) }
- database.metricDao().insertAll(metrics)
- }
-
- // Act - query all 1100 metric IDs
- val result = sessionManager.getSessionsWithMetrics(allMetricIds)
-
- // Assert - session should appear exactly once with all 1100 metrics
- assertEquals(1, result.size)
- assertEquals(sessionId, result[0].session.id)
- assertEquals(1100, result[0].metrics.size)
- }
-
- @Test
- fun `getSessionsWithMetrics filters correctly across chunk boundaries`() =
+ fun `getSessionsWithMetrics returns only requested metrics across query chunks`() =
runTest {
// Arrange - session has 1200 metrics, but we only request 1100 of them
val sessionId = "session-1"
@@ -484,7 +482,7 @@ class SessionManagerTest {
}
@Test
- fun `getSessionsWithMetrics merges multiple sessions across chunks`() =
+ fun `getSessionsWithMetrics groups multiple sessions across query chunks`() =
runTest {
// Arrange - two sessions, each with metrics spanning chunk boundaries
val session1Id = "session-1"
@@ -517,33 +515,82 @@ class SessionManagerTest {
}
@Test
- fun `getSessionsWithMetrics yields empty logs from the chunked merger path`() =
+ fun `getSessionsWithMetrics does not load logs`() =
runTest {
- // Arrange — the chunked-merger branch in `getSessionsWithMetrics`
- // constructs `SessionWithMetrics(...)` without passing logs and relies on
- // the `= emptyList()` default. The session DOES have logs in storage,
- // but the merger projects metrics-only. This test fails loudly if the
- // default is removed or the merger ever needs to surface logs too.
val sessionId = "session-with-logs"
sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z")
-
- val metricIds = (1..1100).map { "metric-$it" }
- metricIds.chunked(500).forEach { chunk ->
- database.metricDao().insertAll(chunk.map { createMetric(it, sessionId) })
- }
- // Also insert logs so an accidental relation-load would surface them.
+ database.metricDao().insertAll(listOf(createMetric("metric-1", sessionId)))
database.logDao().insertAll(listOf(createLog("log-a", sessionId)))
- // Act — 1100 IDs forces the chunked path
- val result = sessionManager.getSessionsWithMetrics(metricIds)
+ val result = sessionManager.getSessionsWithMetrics(listOf("metric-1"))
- // Assert
assertEquals(1, result.size)
assertEquals(emptyList(), result[0].logs)
}
// endregion
+ // region getSessionsWithLogs Tests
+
+ @Test
+ fun `getSessionsWithLogs returns only requested logs`() =
+ runTest {
+ val sessionId = "session-1"
+ sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z")
+ database.logDao().insertAll(
+ listOf(
+ createLog("log-1", sessionId),
+ createLog("log-2", sessionId),
+ createLog("log-3", sessionId)
+ )
+ )
+
+ val result = sessionManager.getSessionsWithLogs(listOf("log-1", "log-3"))
+
+ assertEquals(1, result.size)
+ assertEquals(sessionId, result[0].session.id)
+ assertEquals(setOf("log-1", "log-3"), result[0].logs.map { it.logId }.toSet())
+ }
+
+ @Test
+ fun `getSessionsWithLogs returns unique logs in chronological order`() =
+ runTest {
+ val sessionId = "session-1"
+ sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z")
+ database.logDao().insertAll(
+ listOf(
+ createLog("log-z", sessionId, timestamp = "2025-01-01T00:00:02.000Z"),
+ createLog("log-a", sessionId, timestamp = "2025-01-01T00:00:01.000Z")
+ )
+ )
+ val logIds = listOf("log-z") +
+ (1 until SQLITE_MAX_BIND_VARIABLES).map { "missing-$it" } +
+ listOf("log-a", "log-z")
+
+ val result = sessionManager.getSessionsWithLogs(logIds)
+
+ assertEquals(listOf("log-a", "log-z"), result.single().logs.map { it.logId })
+ }
+
+ @Test
+ fun `getSessionsWithLogs groups requested logs spanning multiple query chunks`() =
+ runTest {
+ val sessionId = "session-1"
+ sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z")
+ val logIds = (1..1100).map { "log-$it" }
+ logIds.chunked(500).forEach { chunk ->
+ database.logDao().insertAll(chunk.map { createLog(it, sessionId) })
+ }
+
+ val result = sessionManager.getSessionsWithLogs(logIds)
+
+ assertEquals(1, result.size)
+ assertEquals(sessionId, result[0].session.id)
+ assertEquals(logIds.toSet(), result[0].logs.map { it.logId }.toSet())
+ }
+
+ // endregion
+
// region Data Cleanup Tests
@Test
@@ -760,12 +807,13 @@ class SessionManagerTest {
sessionId: String,
name: String = "test-metric",
category: String = "test",
- value: Double = 123.45
+ value: Double = 123.45,
+ timestamp: String = "2025-01-01T00:00:00.000Z"
): Metric =
Metric(
metricId = metricId,
sessionId = sessionId,
- timestamp = "2025-01-01T00:00:00.000Z",
+ timestamp = timestamp,
category = category,
name = name,
value = value,
@@ -778,12 +826,13 @@ class SessionManagerTest {
sessionId: String,
name: String = "test.event",
severity: String = "info",
- attributes: String? = null
+ attributes: String? = null,
+ timestamp: String = "2025-01-01T00:00:00.000Z"
): LogRecord =
LogRecord(
logId = logId,
sessionId = sessionId,
- timestamp = "2025-01-01T00:00:00.000Z",
+ timestamp = timestamp,
name = name,
body = null,
severity = severity,
diff --git a/packages/expo-app-metrics/ios/AppMetricsModule.swift b/packages/expo-app-metrics/ios/AppMetricsModule.swift
index 8264a5552b81f6..da004f652f214d 100644
--- a/packages/expo-app-metrics/ios/AppMetricsModule.swift
+++ b/packages/expo-app-metrics/ios/AppMetricsModule.swift
@@ -167,6 +167,14 @@ public final class AppMetricsModule: Module, UpdatesStateChangeListener {
}
public func updatesStateDidChange(_ event: [String: Any]) {
+ // `OnCreate` can run before `EnabledAppController.start()` assigns its startup procedure, in
+ // which case the launched update reads as nil and `AppInfo` keeps the embedded build's
+ // attribution for the rest of the session. Retry here: by the time any state change arrives
+ // the launched update is known, and the patch no-ops once an id has been recorded.
+ AppMetricsActor.isolated {
+ AppMetrics.mainSession.updatesMonitor.patchAppInfoIfNeeded()
+ }
+
if UpdatesStateEvent.fromDict(event)?.type ?? .restart == .downloadCompleteWithUpdate,
let metric = AppMetrics.mainSession.updatesMonitor.downloadTimeMetric(subscription)
{
diff --git a/packages/expo-app-metrics/ios/Storage/AppInfo.swift b/packages/expo-app-metrics/ios/Storage/AppInfo.swift
index 8faedc4518ea44..06d88bddfd32c1 100644
--- a/packages/expo-app-metrics/ios/Storage/AppInfo.swift
+++ b/packages/expo-app-metrics/ios/Storage/AppInfo.swift
@@ -57,8 +57,6 @@ public struct AppInfo: Codable, Equatable, Sendable {
public nonisolated(unsafe) static var current: AppInfo = {
let bundle = Bundle.main
let infoPlist = bundle.infoDictionary ?? [:]
- let updatesInfo = UpdatesMonitoring.getUpdatesMetricsInfo()
-
return AppInfo(
appId: bundle.bundleIdentifier,
appName: (infoPlist["CFBundleDisplayName"] ?? infoPlist["CFBundleName"]) as? String,
diff --git a/packages/expo-doctor/CHANGELOG.md b/packages/expo-doctor/CHANGELOG.md
index f25819cacd10f0..2e52f3e06cb161 100644
--- a/packages/expo-doctor/CHANGELOG.md
+++ b/packages/expo-doctor/CHANGELOG.md
@@ -5,6 +5,7 @@
### 🛠 Breaking changes
- Raise minimum Node.js version to `^22.13.0` ([#47202](https://github.com/expo/expo/pull/47202) by [@kitten](https://github.com/kitten))
+- Use an explicit mode for `.env` files and Expo config. ([#48845](https://github.com/expo/expo/pull/48845) by [@ramonclaudio](https://github.com/ramonclaudio))
### 🎉 New features
@@ -13,6 +14,7 @@
### 🐛 Bug fixes
- [Internal] Prevent `ncc` from removing dynamic requires where we need them ([#48887](https://github.com/expo/expo/pull/48887) by [@kitten](https://github.com/kitten))
+- Keep loaded `.env` values out of `expo install --check`. ([#48845](https://github.com/expo/expo/pull/48845) by [@ramonclaudio](https://github.com/ramonclaudio))
### 💡 Others
diff --git a/packages/expo-doctor/src/__tests__/doctor.test.ts b/packages/expo-doctor/src/__tests__/doctor.test.ts
index 76b8eb54270c57..af3312ffa9a02d 100644
--- a/packages/expo-doctor/src/__tests__/doctor.test.ts
+++ b/packages/expo-doctor/src/__tests__/doctor.test.ts
@@ -1,14 +1,23 @@
+import { loadProjectEnv } from '@expo/env';
+
import { InstalledDependencyVersionCheck } from '../checks/InstalledDependencyVersionCheck';
import { VectorIconsCheck } from '../checks/VectorIconsCheck';
import type { DoctorCheck } from '../checks/checks.types';
import {
+ actionAsync,
printCheckResultSummaryOnComplete,
printFailedCheckIssueAndAdvice,
runChecksAsync,
} from '../doctor';
+import * as CheckResolver from '../utils/checkResolver';
import { resolveChecksInScope } from '../utils/checkResolver';
+import * as ProjectConfig from '../utils/getProjectConfig';
import { Log } from '../utils/log';
+jest.mock('@expo/env', () => ({
+ ...jest.requireActual('@expo/env'),
+ loadProjectEnv: jest.fn(),
+}));
jest.mock(`../utils/log`);
jest.mock('../utils/ora', () => ({
@@ -51,6 +60,55 @@ class MockUnexpectedThrowCheck implements DoctorCheck {
runAsync = jest.fn(() => Promise.reject(new Error('Unexpected error thrown from check.')));
}
+describe(actionAsync, () => {
+ const devGlobal = globalThis as typeof globalThis & { __DEV__?: boolean };
+ const originalDev = devGlobal.__DEV__;
+ const originalConfigMode = process.env.__EXPO_CONFIG_MODE;
+
+ afterEach(() => {
+ devGlobal.__DEV__ = originalDev;
+ if (originalConfigMode === undefined) {
+ delete process.env.__EXPO_CONFIG_MODE;
+ } else {
+ process.env.__EXPO_CONFIG_MODE = originalConfigMode;
+ }
+ jest.mocked(loadProjectEnv).mockReset();
+ jest.mocked(Log.exception).mockReset();
+ jest.restoreAllMocks();
+ });
+
+ it('uses the same mode for env files and Expo config', async () => {
+ process.env.__EXPO_CONFIG_MODE = 'production';
+ jest.spyOn(ProjectConfig, 'getProjectConfigAsync').mockResolvedValue(additionalProjectProps);
+ jest.spyOn(CheckResolver, 'resolveChecksInScope').mockReturnValue([]);
+
+ await actionAsync('/app', false);
+
+ expect(loadProjectEnv).toHaveBeenCalledWith('/app', { mode: 'production' });
+ expect(ProjectConfig.getProjectConfigAsync).toHaveBeenCalledWith('/app', 'production');
+ expect(devGlobal.__DEV__).toBe(false);
+ expect(process.env.__EXPO_CONFIG_MODE).toBeUndefined();
+ });
+
+ it('reports an invalid config mode', async () => {
+ process.env.__EXPO_CONFIG_MODE = 'staging';
+ const getProjectConfigSpy = jest
+ .spyOn(ProjectConfig, 'getProjectConfigAsync')
+ .mockResolvedValue(additionalProjectProps);
+
+ await actionAsync('/app', false);
+
+ expect(Log.exception).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Invalid __EXPO_CONFIG_MODE value: "staging". Use "development" or "production".',
+ })
+ );
+ expect(loadProjectEnv).not.toHaveBeenCalled();
+ expect(getProjectConfigSpy).not.toHaveBeenCalled();
+ expect(process.env.__EXPO_CONFIG_MODE).toBeUndefined();
+ });
+});
+
describe(resolveChecksInScope, () => {
beforeEach(() => {
delete process.env.EXPO_DOCTOR_SKIP_DEPENDENCY_VERSION_CHECK;
diff --git a/packages/expo-doctor/src/checks/InstalledDependencyVersionCheck.ts b/packages/expo-doctor/src/checks/InstalledDependencyVersionCheck.ts
index 86d7d16c284528..5a7b7fbb971b9e 100644
--- a/packages/expo-doctor/src/checks/InstalledDependencyVersionCheck.ts
+++ b/packages/expo-doctor/src/checks/InstalledDependencyVersionCheck.ts
@@ -41,7 +41,7 @@ export class InstalledDependencyVersionCheck implements DoctorCheck {
try {
commandResult = await spawnExpoCLI(projectRoot, ['install', '--check', '--json'], {
stdio: 'pipe',
- env: { ...process.env, CI: '1', EXPO_DEBUG: '0' },
+ env: { CI: '1', EXPO_DEBUG: '0' },
});
} catch (error: any) {
if (isSpawnResult(error) && error.status === 1) {
@@ -61,7 +61,7 @@ export class InstalledDependencyVersionCheck implements DoctorCheck {
try {
await spawnExpoCLI(projectRoot, ['install', '--check'], {
stdio: 'pipe',
- env: { ...process.env, CI: '1', EXPO_DEBUG: '0' },
+ env: { CI: '1', EXPO_DEBUG: '0' },
});
} catch (error: any) {
if (isSpawnResult(error)) {
diff --git a/packages/expo-doctor/src/checks/__tests__/InstalledDependencyVersionCheck.test.ts b/packages/expo-doctor/src/checks/__tests__/InstalledDependencyVersionCheck.test.ts
index b8e72a677bd24a..5170f5e9fd2ce5 100644
--- a/packages/expo-doctor/src/checks/__tests__/InstalledDependencyVersionCheck.test.ts
+++ b/packages/expo-doctor/src/checks/__tests__/InstalledDependencyVersionCheck.test.ts
@@ -1,8 +1,12 @@
+import { loadProjectEnv } from '@expo/env';
import spawnAsync from '@expo/spawn-async';
+import { vol } from 'memfs';
import { mockSpawnPromise } from '../../__tests__/spawn-utils';
import { InstalledDependencyVersionCheck } from '../InstalledDependencyVersionCheck';
+jest.mock('fs');
+
// required by runAsync
const additionalProjectProps = {
exp: {
@@ -48,6 +52,33 @@ describe('runAsync', () => {
expect(mockSpawnAsync.mock.calls[0]![2]).toMatchObject({ env: { CI: '1' } });
});
+ it('does not pass loaded env values to expo install', async () => {
+ const originalEnv = process.env;
+ process.env = { ...originalEnv };
+ delete process.env.EXPO_PUBLIC_DOCTOR_TEST;
+ vol.fromJSON({ '/path/to/project/.env': 'EXPO_PUBLIC_DOCTOR_TEST=from-env' });
+
+ try {
+ loadProjectEnv('/path/to/project', { mode: 'development', silent: true });
+
+ const mockSpawnAsync = jest.mocked(spawnAsync).mockImplementation(() =>
+ mockSpawnPromise(
+ Promise.resolve({
+ stdout: '',
+ })
+ )
+ );
+ const check = new InstalledDependencyVersionCheck();
+ await check.runAsync({ projectRoot: '/path/to/project', ...additionalProjectProps });
+
+ expect(mockSpawnAsync.mock.calls[0]![2]?.env).not.toHaveProperty('EXPO_PUBLIC_DOCTOR_TEST');
+ expect(mockSpawnAsync.mock.calls[0]![2]?.env).not.toHaveProperty('__EXPO_ENV_LOADED');
+ } finally {
+ process.env = originalEnv;
+ vol.reset();
+ }
+ });
+
it('returns result with isSuccessful = false if check fails', async () => {
jest.mocked(spawnAsync).mockImplementation(() => {
const error: any = new Error();
diff --git a/packages/expo-doctor/src/doctor.ts b/packages/expo-doctor/src/doctor.ts
index ac16c5e942c44b..fe0d54db1d1c1d 100644
--- a/packages/expo-doctor/src/doctor.ts
+++ b/packages/expo-doctor/src/doctor.ts
@@ -1,4 +1,4 @@
-import { load as loadEnv } from '@expo/env';
+import { loadProjectEnv, type EnvMode } from '@expo/env';
import chalk from 'chalk';
import type { DoctorCheck, DoctorCheckParams, DoctorCheckResult } from './checks/checks.types';
@@ -8,11 +8,15 @@ import { isNetworkError } from './utils/errors';
import { getProjectConfigAsync } from './utils/getProjectConfig';
import { isInteractive } from './utils/interactive';
import { Log } from './utils/log';
-import { setNodeEnv } from './utils/nodeEnv';
+import { getConfigEnvMode } from './utils/nodeEnv';
import { logNewSection } from './utils/ora';
import { endTimer, formatMilliseconds, startTimer } from './utils/timer';
import { ltSdkVersion } from './utils/versions';
+declare namespace globalThis {
+ let __DEV__: boolean | undefined;
+}
+
interface DoctorCheckRunnerJob {
check: DoctorCheck;
result: DoctorCheckResult;
@@ -123,9 +127,9 @@ export async function runChecksAsync(
);
}
-function maybeLoadEnv(projectRoot: string) {
+function maybeLoadEnv(projectRoot: string, mode: EnvMode) {
try {
- loadEnv(projectRoot);
+ loadProjectEnv(projectRoot, { mode });
} catch {
// NOTE(@kitten): It's unclear why we load env files here in expo-doctor, and it's likely optional, even with us loading the project config
// If this fails, e.g. because the Node.js version is too out of date, ignore the error
@@ -139,10 +143,11 @@ function maybeLoadEnv(projectRoot: string) {
*/
export async function actionAsync(projectRoot: string, showVerboseTestResults: boolean) {
try {
- setNodeEnv('development');
- maybeLoadEnv(projectRoot);
+ const mode = getConfigEnvMode();
+ globalThis.__DEV__ = mode === 'development';
+ maybeLoadEnv(projectRoot, mode);
- const projectConfig = await getProjectConfigAsync(projectRoot);
+ const projectConfig = await getProjectConfigAsync(projectRoot, mode);
// expo-doctor relies on versioned CLI, which is only available for 44+
if (ltSdkVersion(projectConfig.exp, '46.0.0')) {
diff --git a/packages/expo-doctor/src/utils/__tests__/getProjectConfig.test.ts b/packages/expo-doctor/src/utils/__tests__/getProjectConfig.test.ts
index 5e66f8921d7af1..190b0e4f2a408c 100644
--- a/packages/expo-doctor/src/utils/__tests__/getProjectConfig.test.ts
+++ b/packages/expo-doctor/src/utils/__tests__/getProjectConfig.test.ts
@@ -27,7 +27,7 @@ describe(getProjectConfigAsync, () => {
pid: 1234,
});
- const result = await getProjectConfigAsync('/project');
+ const result = await getProjectConfigAsync('/project', 'production');
expect(result).toEqual({
exp: configOutput.exp,
@@ -37,14 +37,10 @@ describe(getProjectConfigAsync, () => {
dynamicConfigPath: null,
});
- expect(mockSpawnExpoCLI).toHaveBeenCalledWith(
- '/project',
- ['config', '--json', '--full'],
- expect.objectContaining({
- stdio: 'pipe',
- env: expect.objectContaining({ EXPO_DEBUG: '0' }),
- })
- );
+ expect(mockSpawnExpoCLI).toHaveBeenCalledWith('/project', ['config', '--json', '--full'], {
+ stdio: 'pipe',
+ env: { __EXPO_CONFIG_MODE: 'production', EXPO_DEBUG: '0' },
+ });
});
it('throws on invalid JSON output', async () => {
@@ -57,7 +53,9 @@ describe(getProjectConfigAsync, () => {
pid: 1234,
});
- await expect(getProjectConfigAsync('/project')).rejects.toThrow(/Failed to parse JSON output/);
+ await expect(getProjectConfigAsync('/project', 'development')).rejects.toThrow(
+ /Failed to parse JSON output/
+ );
});
it('throws when exp or pkg fields are missing', async () => {
@@ -70,7 +68,7 @@ describe(getProjectConfigAsync, () => {
pid: 1234,
});
- await expect(getProjectConfigAsync('/project')).rejects.toThrow(
+ await expect(getProjectConfigAsync('/project', 'development')).rejects.toThrow(
/missing 'exp' or 'pkg' fields/
);
});
@@ -90,7 +88,7 @@ describe(getProjectConfigAsync, () => {
pid: 1234,
});
- const result = await getProjectConfigAsync('/project');
+ const result = await getProjectConfigAsync('/project', 'development');
expect(result.hasUnusedStaticConfig).toBe(false);
expect(result.staticConfigPath).toBeNull();
@@ -110,7 +108,7 @@ describe(getProjectConfigAsync, () => {
pid: 1234,
});
- await expect(getProjectConfigAsync('/project')).rejects.toThrow(
+ await expect(getProjectConfigAsync('/project', 'development')).rejects.toThrow(
/Cannot determine the project's Expo SDK version/
);
});
diff --git a/packages/expo-doctor/src/utils/__tests__/nodeEnv.test.ts b/packages/expo-doctor/src/utils/__tests__/nodeEnv.test.ts
new file mode 100644
index 00000000000000..c081ddcf5a3009
--- /dev/null
+++ b/packages/expo-doctor/src/utils/__tests__/nodeEnv.test.ts
@@ -0,0 +1,22 @@
+import { getConfigEnvMode } from '../nodeEnv';
+
+describe(getConfigEnvMode, () => {
+ afterEach(() => {
+ delete process.env.EAS_BUILD;
+ delete process.env.__EXPO_CONFIG_MODE;
+ });
+
+ it('uses development outside EAS Build when __EXPO_CONFIG_MODE is not set', () => {
+ delete process.env.EAS_BUILD;
+ delete process.env.__EXPO_CONFIG_MODE;
+
+ expect(getConfigEnvMode()).toBe('development');
+ });
+
+ it('uses production in EAS Build when __EXPO_CONFIG_MODE is not set', () => {
+ delete process.env.__EXPO_CONFIG_MODE;
+ process.env.EAS_BUILD = 'true';
+
+ expect(getConfigEnvMode()).toBe('production');
+ });
+});
diff --git a/packages/expo-doctor/src/utils/env.ts b/packages/expo-doctor/src/utils/env.ts
index 27b61a5ffa3781..a532133e916694 100644
--- a/packages/expo-doctor/src/utils/env.ts
+++ b/packages/expo-doctor/src/utils/env.ts
@@ -35,6 +35,11 @@ class Env {
return boolish('EXPO_DOCTOR_WARN_ON_NETWORK_ERRORS', false);
}
+ /** Is running in EAS Build */
+ get EAS_BUILD() {
+ return boolish('EAS_BUILD', false);
+ }
+
/** EAS Build Platform */
get EAS_BUILD_PLATFORM(): 'android' | 'ios' | null {
const easPlatform = process.env.EAS_BUILD_PLATFORM;
diff --git a/packages/expo-doctor/src/utils/getProjectConfig.ts b/packages/expo-doctor/src/utils/getProjectConfig.ts
index 57376150aa301a..9882dfae42ee01 100644
--- a/packages/expo-doctor/src/utils/getProjectConfig.ts
+++ b/packages/expo-doctor/src/utils/getProjectConfig.ts
@@ -1,12 +1,17 @@
+import type { EnvMode } from '@expo/env';
+
import type { DoctorCheckParams } from '../checks/checks.types';
import { spawnExpoCLI } from './spawnExpoCLI';
type ProjectConfig = Omit;
-export async function getProjectConfigAsync(projectRoot: string): Promise {
+export async function getProjectConfigAsync(
+ projectRoot: string,
+ mode: EnvMode
+): Promise {
const result = await spawnExpoCLI(projectRoot, ['config', '--json', '--full'], {
stdio: 'pipe',
- env: { ...process.env, EXPO_DEBUG: '0' },
+ env: { __EXPO_CONFIG_MODE: mode, EXPO_DEBUG: '0' },
});
let parsed: any;
diff --git a/packages/expo-doctor/src/utils/nodeEnv.ts b/packages/expo-doctor/src/utils/nodeEnv.ts
index 1788bfcabbd67a..0f5af7e11c3fa8 100644
--- a/packages/expo-doctor/src/utils/nodeEnv.ts
+++ b/packages/expo-doctor/src/utils/nodeEnv.ts
@@ -1,11 +1,7 @@
-/**
- * Set the environment to production or development
- * lots of tools use this to determine if they should run in a dev mode.
- */
-export function setNodeEnv(mode: 'development' | 'production') {
- process.env.NODE_ENV = process.env.NODE_ENV || mode;
- process.env.BABEL_ENV = process.env.BABEL_ENV || process.env.NODE_ENV;
+import { consumeConfigEnvMode, type EnvMode } from '@expo/env';
- // @ts-expect-error: Add support for external React libraries being loaded in the same process.
- globalThis.__DEV__ = process.env.NODE_ENV !== 'production';
+import { env } from './env';
+
+export function getConfigEnvMode(): EnvMode {
+ return consumeConfigEnvMode() ?? (env.EAS_BUILD ? 'production' : 'development');
}
diff --git a/packages/expo-modules-core/CHANGELOG.md b/packages/expo-modules-core/CHANGELOG.md
index 40b9837522509a..40860d3862171c 100644
--- a/packages/expo-modules-core/CHANGELOG.md
+++ b/packages/expo-modules-core/CHANGELOG.md
@@ -21,6 +21,7 @@
### 🐛 Bug fixes
+- [iOS] Fixed the `ExpoModulesProvider` lookup missing the generated class when the app `name` starts with a digit, which registered no native modules and left release builds on a blank screen. ([#48793](https://github.com/expo/expo/pull/48793) by [@expo-bot](https://github.com/expo-bot))
- [iOS] Fixed the tap that closes a SwiftUI menu also pressing the React Native view underneath it. ([#48419](https://github.com/expo/expo/issues/48419) by [@bohdanstefaniuk](https://github.com/bohdanstefaniuk)) ([#48463](https://github.com/expo/expo/pull/48463) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
- [Android] Fixed hosted Compose views missing layout after reattachment or in-place configuration changes. ([#48370](https://github.com/expo/expo/issues/48370) by [@lujjjh](https://github.com/lujjjh))
- [iOS] Fixed infinite recursion and `EXC_BAD_ACCESS` when encoding native `Date` values to JavaScript. ([#48239](https://github.com/expo/expo/issues/48239), [#48240](https://github.com/expo/expo/pull/48240) by [@samuelcorsan](https://github.com/samuelcorsan))
diff --git a/packages/expo-modules-core/ios/Core/AppContext.swift b/packages/expo-modules-core/ios/Core/AppContext.swift
index be2bd37f66269e..c265d66dd1c2e7 100644
--- a/packages/expo-modules-core/ios/Core/AppContext.swift
+++ b/packages/expo-modules-core/ios/Core/AppContext.swift
@@ -726,9 +726,9 @@ public final class AppContext: NSObject, EXAppContextProtocol, @unchecked Sendab
public static func modulesProvider(withName providerName: String = "ExpoModulesProvider") -> ModulesProvider {
// [0] When ExpoModulesCore is built as separated framework/module,
// we should explicitly load main bundle's `ExpoModulesProvider` class.
- // CFBundleExecutable is tried first: it equals $(PRODUCT_NAME:c99extidentifier) and
- // directly matches the Swift module name. CFBundleName is kept as a fallback for the
- // uncommon case where both values are identical valid identifiers.
+ // CFBundleExecutable is tried first: it is the product name, from which the Swift module
+ // name is derived. CFBundleName is kept as a fallback for the uncommon case where both
+ // values are identical valid identifiers.
let mainBundleNames = [
Bundle.main.infoDictionary?["CFBundleExecutable"],
Bundle.main.infoDictionary?["CFBundleName"]
@@ -765,12 +765,27 @@ public final class AppContext: NSObject, EXAppContextProtocol, @unchecked Sendab
internal static func moduleProviderClassNames(withName providerName: String, bundleNames: [String]) -> [String] {
var seen = Set()
- return bundleNames.compactMap { bundleName in
+ return bundleNames.flatMap { [$0, c99ExtendedIdentifier($0)] }.compactMap { bundleName in
let candidate = "\(bundleName).\(providerName)"
return seen.insert(candidate).inserted ? candidate : nil
}
}
+ /**
+ Applies the same substitution as Xcode's `:c99extidentifier` string operator, which derives the
+ default `PRODUCT_MODULE_NAME` (and thus the Swift module name) from `PRODUCT_NAME`. The bundle
+ names above are the raw product name, so the two differ whenever the product name is not a valid
+ C99 identifier — most commonly when the app's `name` starts with a digit, where a product name of
+ `123myapp` compiles into the Swift module `_23myapp`.
+ */
+ internal static func c99ExtendedIdentifier(_ name: String) -> String {
+ var characters = name.map { $0.isLetter || $0.isNumber || $0 == "_" ? $0 : "_" }
+ if let first = characters.first, first.isNumber {
+ characters[0] = "_"
+ }
+ return String(characters)
+ }
+
public func reloadAppAsync(_ reason: String = "Reload from appContext") {
if moduleRegistry.has(moduleWithName: "ExpoGo") {
NotificationCenter.default.post(name: NSNotification.Name(rawValue: "EXReloadActiveAppRequest"), object: nil)
diff --git a/packages/expo-modules-core/ios/Tests/AppContextTests.swift b/packages/expo-modules-core/ios/Tests/AppContextTests.swift
index 4469a5907fe2ac..4fa921e1459b8e 100644
--- a/packages/expo-modules-core/ios/Tests/AppContextTests.swift
+++ b/packages/expo-modules-core/ios/Tests/AppContextTests.swift
@@ -57,8 +57,8 @@ struct AppContextTests {
@Test
func `preserves order and emits both candidates when CFBundleExecutable differs from CFBundleName`() {
// When CFBundleExecutable differs from CFBundleName (e.g. dotted bundle name), both
- // candidates are emitted with the executable-derived one first, since it is the Swift
- // module name by construction.
+ // candidates are emitted with the executable-derived one first, since the Swift module name
+ // is derived from it.
let classNames = AppContext.moduleProviderClassNames(
withName: "ExpoModulesProvider",
bundleNames: ["Universal_internal", "Universal.internal"]
@@ -70,6 +70,22 @@ struct AppContextTests {
])
}
+ @Test
+ func `also emits the c99-mangled candidate for a digit-first bundle name`() {
+ // `CFBundleExecutable` is the raw product name, but Xcode derives the Swift module name from
+ // `$(PRODUCT_NAME:c99extidentifier)`, so an app named `123myapp` builds into the module
+ // `_23myapp` and the unmangled candidate misses the generated provider entirely.
+ let classNames = AppContext.moduleProviderClassNames(
+ withName: "ExpoModulesProvider",
+ bundleNames: ["123myapp"]
+ )
+
+ #expect(classNames == [
+ "123myapp.ExpoModulesProvider",
+ "_23myapp.ExpoModulesProvider"
+ ])
+ }
+
// MARK: - NativeState
@Suite("NativeState")
diff --git a/packages/expo-observe/CHANGELOG.md b/packages/expo-observe/CHANGELOG.md
index 836c03811d5749..99f1c7d972b3f8 100644
--- a/packages/expo-observe/CHANGELOG.md
+++ b/packages/expo-observe/CHANGELOG.md
@@ -17,6 +17,8 @@
### 💡 Others
+- [Android] Retry a dispatch that gets HTTP 413 ([#49016](https://github.com/expo/expo/pull/49016) by [@Ubax](https://github.com/Ubax))
+- [Android] Dispatch pending metrics and logs in bounded, oldest-first chunks without replacing active background work. ([#49012](https://github.com/expo/expo/pull/49012) by [@Ubax](https://github.com/Ubax))
- Mark the `AppMetrics` export as deprecated in favor of `Observe`. ([#48901](https://github.com/expo/expo/pull/48901) by [@kadikraman](https://github.com/kadikraman))
## 57.0.9 — 2026-07-29
diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/Constants.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/Constants.kt
index 0233592bac2fcc..5381840e692c7d 100644
--- a/packages/expo-observe/android/src/main/java/expo/modules/observe/Constants.kt
+++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/Constants.kt
@@ -3,4 +3,5 @@ package expo.modules.observe
internal const val OBSERVE_TAG = "EasObserve"
internal const val OBSERVE_DEFAULT_BASE_URL = "https://o.expo.dev/"
+internal const val DISPATCH_CHUNK_SIZE = 200
internal const val SQLITE_MAX_BIND_VARIABLES = 900
diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/DispatchUtils.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/DispatchUtils.kt
index 94178829871b9a..b34a3018191e0c 100644
--- a/packages/expo-observe/android/src/main/java/expo/modules/observe/DispatchUtils.kt
+++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/DispatchUtils.kt
@@ -11,7 +11,7 @@ import kotlin.math.pow
import kotlin.random.Random
/**
- * Outcome of a single dispatch attempt to the OTLP endpoint. Four cases, modeled after the
+ * Outcome of a single dispatch attempt to the OTLP endpoint. Five cases, modeled after the
* OTLP retry guidance (see https://opentelemetry.io/docs/specs/otlp/#otlphttp-response):
*
* - `Success` — server accepted the batch without rejections.
@@ -22,6 +22,7 @@ import kotlin.random.Random
* it as a drop.
* - `RetryableFailure` — transient failure (429/502/503/504 or transport error); retry
* the same batch after `retryAfterMs` or a client-computed backoff.
+ * - `PayloadTooLarge` — HTTP 413; retry immediately with fewer records.
* - `NonRetryableFailure` — permanent failure (4xx/5xx outside the retryable set, encoding
* error); drop the batch so it can't wedge the queue.
*
@@ -33,6 +34,7 @@ sealed class DispatchResult {
object Success : DispatchResult()
data class PartialSuccess(val partial: OTPartialSuccess) : DispatchResult()
data class RetryableFailure(val retryAfterMs: Long? = null) : DispatchResult()
+ object PayloadTooLarge : DispatchResult()
data class NonRetryableFailure(val reason: String) : DispatchResult()
}
@@ -43,7 +45,7 @@ sealed class DispatchResult {
*/
object DispatchUtils {
/**
- * Pure classifier that maps an HTTP response into one of three retry outcomes. Extracted
+ * Pure classifier that maps an HTTP response into a dispatch outcome. Extracted
* from the dispatch call site so the OTLP-spec rules can be unit-tested without a real
* network call.
*
@@ -57,8 +59,6 @@ object DispatchUtils {
responseBody: String?,
bodyExcerpt: () -> String = { "" }
): DispatchResult {
- val retryAfter = parseRetryAfter(retryAfterHeader)
-
if (statusCode in 200..299) {
// The OTLP spec allows `partial_success` to carry a warning-only payload —
// `rejectedCount == 0` with a non-empty `errorMessage`. Treat that as a successful send
@@ -78,7 +78,8 @@ object DispatchUtils {
}
return when (statusCode) {
- 429, 502, 503, 504 -> DispatchResult.RetryableFailure(retryAfter)
+ 413 -> DispatchResult.PayloadTooLarge
+ 429, 502, 503, 504 -> DispatchResult.RetryableFailure(parseRetryAfter(retryAfterHeader))
else -> {
val excerpt = bodyExcerpt()
val suffix = if (excerpt.isEmpty()) "" else ": $excerpt"
@@ -97,12 +98,15 @@ object DispatchUtils {
* permanently, so retrying would produce the same answer; removing them drops the batch
* so it can't wedge subsequent rounds. This is the acceptance-criterion behavior: a
* 400/403 must not be re-sent on the next cycle.
- * - `RetryableFailure` keeps them so the next dispatch round picks the same rows up again.
+ * - `PayloadTooLarge` removes the pending ID because multi-record batches are retried with
+ * smaller chunks before this check, so reaching it means a single record exceeded the limit.
+ * - `RetryableFailure` keeps them so they can be retried.
*/
fun shouldRemovePending(result: DispatchResult): Boolean = when (result) {
is DispatchResult.Success,
is DispatchResult.PartialSuccess,
- is DispatchResult.NonRetryableFailure -> true
+ is DispatchResult.NonRetryableFailure,
+ is DispatchResult.PayloadTooLarge -> true
is DispatchResult.RetryableFailure -> false
}
@@ -218,6 +222,8 @@ object DispatchUtils {
* server-side) doesn't introduce a new pause.
* - `NonRetryableFailure` also resets the counter. A permanent drop isn't a sign that the
* server is unhealthy and shouldn't pause subsequent rounds.
+ * - `PayloadTooLarge` also resets the counter because a reachable server returned a definitive
+ * response, and leaves the gate alone because chunk-size retries are immediate.
* - `RetryableFailure` increments the counter and sets the gate to `now + delay`, where
* `delay` is the server-supplied `retryAfterMs` if present, otherwise `backoff(nextCount)`.
*
@@ -232,7 +238,8 @@ object DispatchUtils {
): RetryGateState = when (result) {
is DispatchResult.Success,
is DispatchResult.PartialSuccess,
- is DispatchResult.NonRetryableFailure ->
+ is DispatchResult.NonRetryableFailure,
+ is DispatchResult.PayloadTooLarge ->
currentState.copy(consecutiveRetryableFailures = 0)
is DispatchResult.RetryableFailure -> {
val nextCount = currentState.consecutiveRetryableFailures + 1
diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/EventDispatcher.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/EventDispatcher.kt
index 0637d1a1217768..f445ff0c0eeff9 100644
--- a/packages/expo-observe/android/src/main/java/expo/modules/observe/EventDispatcher.kt
+++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/EventDispatcher.kt
@@ -132,6 +132,8 @@ class EventDispatcher(
)
is DispatchResult.RetryableFailure ->
Log.w(OBSERVE_TAG, "Server responded with ${response.code} (retryable) and data: $responseBody")
+ is DispatchResult.PayloadTooLarge ->
+ Log.w(OBSERVE_TAG, "Server responded with ${response.code} (payload too large) and data: $responseBody")
is DispatchResult.NonRetryableFailure ->
Log.w(
OBSERVE_TAG,
diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityBackgroundWorker.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityBackgroundWorker.kt
index 465fd4ba3c371c..e41d7f3ceb58a6 100644
--- a/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityBackgroundWorker.kt
+++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityBackgroundWorker.kt
@@ -119,7 +119,8 @@ class ObservabilityBackgroundWorker(
.getInstance(context)
.enqueueUniqueWork(
WORK_NAME,
- ExistingWorkPolicy.REPLACE,
+ // Keep an in-flight dispatch; cancelling it can duplicate a request the server received.
+ ExistingWorkPolicy.KEEP,
periodicWork
)
}
diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityManager.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityManager.kt
index 6b451d15a4815f..eaeab04e8e6bdc 100644
--- a/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityManager.kt
+++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityManager.kt
@@ -8,6 +8,8 @@ import expo.modules.observe.storage.PendingMetricsManager
import expo.modules.appmetrics.storage.SessionManager
import expo.modules.appmetrics.utils.TimeUtils
import expo.modules.interfaces.constants.ConstantsInterface
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -81,7 +83,8 @@ class BaseObservabilityManager(
private val deterministicUniformValueProvider: () -> Double = {
EASClientID.deterministicUniformValue(EASClientID(context).uuid)
},
- private val currentTimeMs: () -> Long = { TimeUtils.getWallClockMillis() }
+ private val currentTimeMs: () -> Long = { TimeUtils.getWallClockMillis() },
+ private val dispatchChunkSize: Int = DISPATCH_CHUNK_SIZE
) {
private val eventDispatcher = EventDispatcher(
context = context,
@@ -149,8 +152,7 @@ class BaseObservabilityManager(
)
suspend fun dispatchUnsentMetrics(): Unit = metricsDispatchMutex.withLock {
- val pendingIds = pendingMetricsManager.getAllPendingMetricIds()
- if (pendingIds.isEmpty()) {
+ if (!pendingMetricsManager.hasPendingMetrics()) {
return
}
@@ -159,50 +161,74 @@ class BaseObservabilityManager(
}
if (!shouldDispatch()) {
- pendingMetricsManager.removePendingMetrics(pendingIds)
+ pendingMetricsManager.removeAllPendingMetrics()
return
}
- val sessionsWithPendingMetrics = sessionManager.getSessionsWithMetrics(pendingIds)
-
- // Clean up orphaned pending IDs (metrics deleted from MetricsDatabase but still in pending table)
- val resolvedMetricIds = sessionsWithPendingMetrics.flatMap { it.metrics }.map { it.metricId }.toSet()
- val orphanedIds = pendingIds.filter { it !in resolvedMetricIds }
- if (orphanedIds.isNotEmpty()) {
- pendingMetricsManager.removePendingMetrics(orphanedIds)
- }
-
- if (sessionsWithPendingMetrics.isEmpty()) {
- return
- }
-
- val events = sessionsWithPendingMetrics.map { sessionWithMetrics ->
- Event(
- metadata = Metadata.fromSessionMetadata(sessionWithMetrics.session),
- metrics = sessionWithMetrics.metrics.map { EASMetric.fromMetric(it) }
- )
- }
-
- val result = eventDispatcher.dispatch(events)
- metricsRetryGate = nextGate(metricsRetryGate, result)
- val dispatchedMetricIds = sessionsWithPendingMetrics.flatMap { it.metrics }.map { it.metricId }
- if (DispatchUtils.shouldRemovePending(result)) {
- pendingMetricsManager.removePendingMetrics(dispatchedMetricIds)
- }
- when (result) {
- is DispatchResult.PartialSuccess ->
- Log.w(
- OBSERVE_TAG,
- "Partial success on batch of ${dispatchedMetricIds.size} metric event(s): " +
- "server rejected ${result.partial.rejectedCount} " +
- "(${result.partial.errorMessage ?: "no error message"})"
- )
- is DispatchResult.NonRetryableFailure ->
- Log.w(
- OBSERVE_TAG,
- "Dropping batch of ${dispatchedMetricIds.size} metric event(s): ${result.reason}"
- )
- is DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit
+ var chunkSize = dispatchChunkSize
+ while (currentCoroutineContext().isActive) {
+ val pendingIds = pendingMetricsManager.getPendingMetricIds(chunkSize)
+ // Use the default for the next batch unless a 413 below overrides it. Re-discovering
+ // the limit each batch is fine: the server accepts payloads over 1 MB, so the default
+ // chunk stays far below the limit and a 413 is exceptional.
+ chunkSize = dispatchChunkSize
+ if (pendingIds.isEmpty()) {
+ break
+ }
+
+ val sessionsWithPendingMetrics = sessionManager.getSessionsWithMetrics(pendingIds)
+
+ // Clean up orphaned pending IDs (metrics deleted from MetricsDatabase but still in pending table)
+ val resolvedMetricIds = sessionsWithPendingMetrics.flatMap { it.metrics }.map { it.metricId }.toSet()
+ val orphanedIds = pendingIds.filter { it !in resolvedMetricIds }
+ if (orphanedIds.isNotEmpty()) {
+ pendingMetricsManager.removePendingMetrics(orphanedIds)
+ }
+
+ if (sessionsWithPendingMetrics.isNotEmpty()) {
+ val events = sessionsWithPendingMetrics.map { sessionWithMetrics ->
+ Event(
+ metadata = Metadata.fromSessionMetadata(sessionWithMetrics.session),
+ metrics = sessionWithMetrics.metrics.map { EASMetric.fromMetric(it) }
+ )
+ }
+
+ val result = eventDispatcher.dispatch(events)
+ metricsRetryGate = nextGate(metricsRetryGate, result)
+ val dispatchedMetricIds = sessionsWithPendingMetrics.flatMap { it.metrics }.map { it.metricId }
+ when (result) {
+ is DispatchResult.PartialSuccess ->
+ Log.w(
+ OBSERVE_TAG,
+ "Partial success on batch of ${dispatchedMetricIds.size} metric event(s): " +
+ "server rejected ${result.partial.rejectedCount} " +
+ "(${result.partial.errorMessage ?: "no error message"})"
+ )
+ is DispatchResult.NonRetryableFailure ->
+ Log.w(
+ OBSERVE_TAG,
+ "Dropping batch of ${dispatchedMetricIds.size} metric event(s): ${result.reason}"
+ )
+ is DispatchResult.PayloadTooLarge ->
+ if (dispatchedMetricIds.size == 1) {
+ Log.w(OBSERVE_TAG, "Dropping metric event that exceeds the server's payload limit")
+ }
+ is DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit
+ }
+ if (result is DispatchResult.PayloadTooLarge && dispatchedMetricIds.size > 1) {
+ chunkSize = dispatchedMetricIds.size / 2
+ // Keep the pending metrics, but retry immediately with a smaller chunk.
+ continue
+ }
+ if (!DispatchUtils.shouldRemovePending(result)) {
+ break
+ }
+ pendingMetricsManager.removePendingMetrics(dispatchedMetricIds)
+ // A systematic rejection or an oversized record: leave the rest for the next run.
+ if (result is DispatchResult.NonRetryableFailure || result is DispatchResult.PayloadTooLarge) {
+ break
+ }
+ }
}
}
@@ -211,8 +237,7 @@ class BaseObservabilityManager(
* a logs failure doesn't affect the metrics pending table and vice versa.
*/
suspend fun dispatchUnsentLogs(): Unit = logsDispatchMutex.withLock {
- val pendingIds = pendingLogsManager.getAllPendingLogIds()
- if (pendingIds.isEmpty()) {
+ if (!pendingLogsManager.hasPendingLogs()) {
return
}
@@ -221,52 +246,76 @@ class BaseObservabilityManager(
}
if (!shouldDispatch()) {
- pendingLogsManager.removePendingLogs(pendingIds)
+ pendingLogsManager.removeAllPendingLogs()
return
}
- val sessionsWithPendingLogs = sessionManager.getSessionsWithLogs(pendingIds)
-
- // Clean up orphaned pending IDs (logs deleted from the `logs` table but
- // still tracked in `pending_logs`).
- val resolvedLogIds = sessionsWithPendingLogs.flatMap { it.logs }.map { it.logId }.toSet()
- val orphanedIds = pendingIds.filter { it !in resolvedLogIds }
- if (orphanedIds.isNotEmpty()) {
- pendingLogsManager.removePendingLogs(orphanedIds)
- }
-
- if (sessionsWithPendingLogs.isEmpty()) {
- return
- }
-
- val events = sessionsWithPendingLogs.map { sessionWithLogs ->
- Event(
- metadata = Metadata.fromSessionMetadata(sessionWithLogs.session),
- metrics = emptyList(),
- logs = sessionWithLogs.logs.map { LogEvent.fromLogRecord(it) }
- )
- }
-
- val result = eventDispatcher.dispatchLogs(events)
- logsRetryGate = nextGate(logsRetryGate, result)
- val dispatchedLogIds = sessionsWithPendingLogs.flatMap { it.logs }.map { it.logId }
- if (DispatchUtils.shouldRemovePending(result)) {
- pendingLogsManager.removePendingLogs(dispatchedLogIds)
- }
- when (result) {
- is DispatchResult.PartialSuccess ->
- Log.w(
- OBSERVE_TAG,
- "Partial success on batch of ${dispatchedLogIds.size} log event(s): " +
- "server rejected ${result.partial.rejectedCount} " +
- "(${result.partial.errorMessage ?: "no error message"})"
- )
- is DispatchResult.NonRetryableFailure ->
- Log.w(
- OBSERVE_TAG,
- "Dropping batch of ${dispatchedLogIds.size} log event(s): ${result.reason}"
- )
- is DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit
+ var chunkSize = dispatchChunkSize
+ while (currentCoroutineContext().isActive) {
+ val pendingIds = pendingLogsManager.getPendingLogIds(chunkSize)
+ // Use the default for the next batch unless a 413 below overrides it. Re-discovering
+ // the limit each batch is fine: the server accepts payloads over 1 MB, so the default
+ // chunk stays far below the limit and a 413 is exceptional.
+ chunkSize = dispatchChunkSize
+ if (pendingIds.isEmpty()) {
+ break
+ }
+
+ val sessionsWithPendingLogs = sessionManager.getSessionsWithLogs(pendingIds)
+
+ // Clean up orphaned pending IDs (logs deleted from the `logs` table but
+ // still tracked in `pending_logs`).
+ val resolvedLogIds = sessionsWithPendingLogs.flatMap { it.logs }.map { it.logId }.toSet()
+ val orphanedIds = pendingIds.filter { it !in resolvedLogIds }
+ if (orphanedIds.isNotEmpty()) {
+ pendingLogsManager.removePendingLogs(orphanedIds)
+ }
+
+ if (sessionsWithPendingLogs.isNotEmpty()) {
+ val events = sessionsWithPendingLogs.map { sessionWithLogs ->
+ Event(
+ metadata = Metadata.fromSessionMetadata(sessionWithLogs.session),
+ metrics = emptyList(),
+ logs = sessionWithLogs.logs.map { LogEvent.fromLogRecord(it) }
+ )
+ }
+
+ val result = eventDispatcher.dispatchLogs(events)
+ logsRetryGate = nextGate(logsRetryGate, result)
+ val dispatchedLogIds = sessionsWithPendingLogs.flatMap { it.logs }.map { it.logId }
+ when (result) {
+ is DispatchResult.PartialSuccess ->
+ Log.w(
+ OBSERVE_TAG,
+ "Partial success on batch of ${dispatchedLogIds.size} log event(s): " +
+ "server rejected ${result.partial.rejectedCount} " +
+ "(${result.partial.errorMessage ?: "no error message"})"
+ )
+ is DispatchResult.NonRetryableFailure ->
+ Log.w(
+ OBSERVE_TAG,
+ "Dropping batch of ${dispatchedLogIds.size} log event(s): ${result.reason}"
+ )
+ is DispatchResult.PayloadTooLarge ->
+ if (dispatchedLogIds.size == 1) {
+ Log.w(OBSERVE_TAG, "Dropping log event that exceeds the server's payload limit")
+ }
+ is DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit
+ }
+ if (result is DispatchResult.PayloadTooLarge && dispatchedLogIds.size > 1) {
+ chunkSize = dispatchedLogIds.size / 2
+ // Keep the pending logs, but retry immediately with a smaller chunk.
+ continue
+ }
+ if (!DispatchUtils.shouldRemovePending(result)) {
+ break
+ }
+ pendingLogsManager.removePendingLogs(dispatchedLogIds)
+ // A systematic rejection or an oversized record: leave the rest for the next run.
+ if (result is DispatchResult.NonRetryableFailure || result is DispatchResult.PayloadTooLarge) {
+ break
+ }
+ }
}
}
diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/ObserveDatabase.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/ObserveDatabase.kt
index 24245ed19b8487..3292453a69881e 100644
--- a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/ObserveDatabase.kt
+++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/ObserveDatabase.kt
@@ -11,6 +11,8 @@ import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
+// TODO: Draining large backlogs from both pending tables reruns `ORDER BY addedAt`
+// for every chunk. Consider `Index("addedAt")` and a version bump if this matters in practice.
@Entity(tableName = "pending_metrics")
data class PendingMetric(
@PrimaryKey val metricId: String,
@@ -30,8 +32,14 @@ interface PendingMetricDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(metrics: List)
- @Query("SELECT metricId FROM pending_metrics")
- suspend fun getAllMetricIds(): List
+ @Query("SELECT metricId FROM pending_metrics ORDER BY addedAt ASC LIMIT :limit")
+ suspend fun getMetricIds(limit: Int): List
+
+ @Query("SELECT EXISTS(SELECT 1 FROM pending_metrics)")
+ suspend fun hasMetricIds(): Boolean
+
+ @Query("DELETE FROM pending_metrics")
+ suspend fun deleteAll()
@Query("DELETE FROM pending_metrics WHERE metricId IN (:metricIds)")
suspend fun deleteByIds(metricIds: List)
@@ -45,8 +53,14 @@ interface PendingLogDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(logs: List)
- @Query("SELECT logId FROM pending_logs")
- suspend fun getAllLogIds(): List
+ @Query("SELECT logId FROM pending_logs ORDER BY addedAt ASC LIMIT :limit")
+ suspend fun getLogIds(limit: Int): List
+
+ @Query("SELECT EXISTS(SELECT 1 FROM pending_logs)")
+ suspend fun hasLogIds(): Boolean
+
+ @Query("DELETE FROM pending_logs")
+ suspend fun deleteAll()
@Query("DELETE FROM pending_logs WHERE logId IN (:logIds)")
suspend fun deleteByIds(logIds: List)
diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingLogsManager.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingLogsManager.kt
index 5d2cc4b4316de5..542e2268242ebc 100644
--- a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingLogsManager.kt
+++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingLogsManager.kt
@@ -17,7 +17,11 @@ class PendingLogsManager(
database.pendingLogDao().insertAll(pendingLogs)
}
- suspend fun getAllPendingLogIds(): List = database.pendingLogDao().getAllLogIds()
+ suspend fun getPendingLogIds(limit: Int): List = database.pendingLogDao().getLogIds(limit)
+
+ suspend fun hasPendingLogs(): Boolean = database.pendingLogDao().hasLogIds()
+
+ suspend fun removeAllPendingLogs() = database.pendingLogDao().deleteAll()
suspend fun removePendingLogs(logIds: List) {
database.withTransaction {
diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingMetricsManager.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingMetricsManager.kt
index 44b5d597887ce4..7db4f82fbf3190 100644
--- a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingMetricsManager.kt
+++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingMetricsManager.kt
@@ -17,7 +17,11 @@ class PendingMetricsManager(
database.pendingMetricDao().insertAll(pendingMetrics)
}
- suspend fun getAllPendingMetricIds(): List = database.pendingMetricDao().getAllMetricIds()
+ suspend fun getPendingMetricIds(limit: Int): List = database.pendingMetricDao().getMetricIds(limit)
+
+ suspend fun hasPendingMetrics(): Boolean = database.pendingMetricDao().hasMetricIds()
+
+ suspend fun removeAllPendingMetrics() = database.pendingMetricDao().deleteAll()
suspend fun removePendingMetrics(metricIds: List) {
database.withTransaction {
diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/BaseObservabilityManagerTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/BaseObservabilityManagerTest.kt
index 60e5aae1d5119a..a4089870f26cba 100644
--- a/packages/expo-observe/android/src/test/java/expo/modules/observe/BaseObservabilityManagerTest.kt
+++ b/packages/expo-observe/android/src/test/java/expo/modules/observe/BaseObservabilityManagerTest.kt
@@ -10,6 +10,9 @@ import expo.modules.appmetrics.storage.SessionWithLogs
import expo.modules.appmetrics.storage.SessionWithMetrics
import expo.modules.appmetrics.utils.TimeUtils
import io.mockk.*
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@@ -40,6 +43,8 @@ class BaseObservabilityManagerTest {
mockEventDispatcher = mockk(relaxed = true)
mockPendingMetricsManager = mockk(relaxed = true)
mockPendingLogsManager = mockk(relaxed = true)
+ coEvery { mockPendingMetricsManager.hasPendingMetrics() } returns true
+ coEvery { mockPendingLogsManager.hasPendingLogs() } returns true
// Default to enabled so existing tests aren't short-circuited
mockkObject(ObservePreferences)
@@ -59,12 +64,6 @@ class BaseObservabilityManagerTest {
runTest {
// Arrange
every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchingEnabled = false)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1", "id2")
-
- val removedIds = mutableListOf()
- coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers {
- removedIds.addAll(firstArg>())
- }
val manager = createManager()
@@ -77,10 +76,8 @@ class BaseObservabilityManagerTest {
// Assert - sessions are never fetched
coVerify(exactly = 0) { mockSessionManager.getSessionsWithMetrics(any()) }
- // Assert - all pending metric IDs are removed
- assertEquals(2, removedIds.size)
- assertTrue("id1 should be removed", removedIds.contains("id1"))
- assertTrue("id2 should be removed", removedIds.contains("id2"))
+ coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() }
+ coVerify(exactly = 0) { mockPendingMetricsManager.getPendingMetricIds(any()) }
}
@Test
@@ -95,7 +92,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(devMetric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("dev-metric-id")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("dev-metric-id"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(devSession)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -133,7 +130,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(devMetric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("dev-metric-id")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("dev-metric-id"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(devSession)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -171,7 +168,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(prodMetric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("prod-metric-id")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("prod-metric-id"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(prodSession)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -206,7 +203,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(devMetric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("dev-metric-id")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("dev-metric-id"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(devSession)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -230,23 +227,16 @@ class BaseObservabilityManagerTest {
runTest {
// Arrange — explicit opt-out behaves like the default on debug builds.
every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = false)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1", "id2")
-
- val removedIds = mutableListOf()
- coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers {
- removedIds.addAll(firstArg>())
- }
val manager = createManager(isDebugBuild = true)
// Act
manager.dispatchUnsentMetrics()
- // Assert — short-circuit: no session lookup, no dispatch, single removePendingMetrics call.
+ // Assert — short-circuit: no session lookup, no dispatch, and the pending table is cleared.
coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) }
coVerify(exactly = 0) { mockSessionManager.getSessionsWithMetrics(any()) }
- coVerify(exactly = 1) { mockPendingMetricsManager.removePendingMetrics(listOf("id1", "id2")) }
- assertEquals(2, removedIds.size)
+ coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() }
}
@Test
@@ -261,7 +251,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(prodMetric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("prod-metric-id")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("prod-metric-id"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(prodSession)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -282,9 +272,6 @@ class BaseObservabilityManagerTest {
dispatchingEnabled = false,
dispatchInDebug = true
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
- coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } just runs
-
val manager = createManager(isDebugBuild = true)
// Act
@@ -292,7 +279,7 @@ class BaseObservabilityManagerTest {
// Assert
coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) }
- coVerify(exactly = 1) { mockPendingMetricsManager.removePendingMetrics(listOf("id1")) }
+ coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() }
}
// endregion
@@ -306,9 +293,6 @@ class BaseObservabilityManagerTest {
every { ObservePreferences.getBundleDefaults(any()) } returns
PersistedBundleDefaults(environment = "development", isJsDev = true)
every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = false)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
- coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } just runs
-
val manager = createManager(isDebugBuild = false)
// Act
@@ -316,7 +300,7 @@ class BaseObservabilityManagerTest {
// Assert
coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) }
- coVerify(exactly = 1) { mockPendingMetricsManager.removePendingMetrics(listOf("id1")) }
+ coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() }
}
@Test
@@ -333,7 +317,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(devMetric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("dev-metric-id")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("dev-metric-id"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(devSession)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } just runs
@@ -361,7 +345,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(prodMetric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("prod-metric-id")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("prod-metric-id"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(prodSession)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } just runs
@@ -381,9 +365,6 @@ class BaseObservabilityManagerTest {
// Arrange — cold start before JS has run. isJsDev defaults to false.
every { ObservePreferences.getBundleDefaults(any()) } returns null
every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = false)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
- coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } just runs
-
val manager = createManager(isDebugBuild = true)
// Act
@@ -391,7 +372,7 @@ class BaseObservabilityManagerTest {
// Assert — isDebugBuild alone gates dispatch.
coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) }
- coVerify(exactly = 1) { mockPendingMetricsManager.removePendingMetrics(listOf("id1")) }
+ coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() }
}
// endregion
@@ -409,7 +390,7 @@ class BaseObservabilityManagerTest {
environment = "production",
metrics = listOf(metric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -434,7 +415,7 @@ class BaseObservabilityManagerTest {
environment = "production",
metrics = listOf(metric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -452,12 +433,6 @@ class BaseObservabilityManagerTest {
runTest {
// Arrange — sampleRate = 0.5, device value = 0.8 → out-of-sample
every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 0.5)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1", "id2")
-
- val removedIds = mutableListOf()
- coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers {
- removedIds.addAll(firstArg>())
- }
val manager = createManager(deterministicUniformValue = 0.8)
@@ -467,7 +442,7 @@ class BaseObservabilityManagerTest {
// Assert — no dispatch, pending is cleared
coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) }
coVerify(exactly = 0) { mockSessionManager.getSessionsWithMetrics(any()) }
- assertEquals(listOf("id1", "id2"), removedIds)
+ coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() }
}
@Test
@@ -475,12 +450,6 @@ class BaseObservabilityManagerTest {
runTest {
// Arrange — sampleRate = 0.5, device value = 0.5 → out-of-sample (comparison is strict <).
every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 0.5)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1", "id2")
-
- val removedIds = mutableListOf()
- coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers {
- removedIds.addAll(firstArg>())
- }
val manager = createManager(deterministicUniformValue = 0.5)
@@ -490,7 +459,7 @@ class BaseObservabilityManagerTest {
// Assert — no dispatch, pending is cleared
coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) }
coVerify(exactly = 0) { mockSessionManager.getSessionsWithMetrics(any()) }
- assertEquals(listOf("id1", "id2"), removedIds)
+ coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() }
}
@Test
@@ -498,9 +467,6 @@ class BaseObservabilityManagerTest {
runTest {
// Arrange — any deterministic value is >= 0, so sampleRate=0 → out.
every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 0.0)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
- coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } just runs
-
val manager = createManager(deterministicUniformValue = 0.0)
// Act
@@ -508,7 +474,7 @@ class BaseObservabilityManagerTest {
// Assert
coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) }
- coVerify(exactly = 1) { mockPendingMetricsManager.removePendingMetrics(listOf("id1")) }
+ coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() }
}
@Test
@@ -522,7 +488,7 @@ class BaseObservabilityManagerTest {
environment = "production",
metrics = listOf(metric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -546,7 +512,7 @@ class BaseObservabilityManagerTest {
environment = "production",
metrics = listOf(metric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -564,9 +530,6 @@ class BaseObservabilityManagerTest {
runTest {
// Arrange — -0.5 → clamped to 0.0 → out-of-sample.
every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = -0.5)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
- coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } just runs
-
val manager = createManager(deterministicUniformValue = 0.0)
// Act
@@ -574,7 +537,7 @@ class BaseObservabilityManagerTest {
// Assert
coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) }
- coVerify(exactly = 1) { mockPendingMetricsManager.removePendingMetrics(listOf("id1")) }
+ coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() }
}
@Test
@@ -582,9 +545,6 @@ class BaseObservabilityManagerTest {
runTest {
// Arrange — dispatchingEnabled=false wins over sampleRate=1.0.
every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchingEnabled = false, sampleRate = 1.0)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
- coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } just runs
-
val manager = createManager(deterministicUniformValue = 0.0)
// Act
@@ -592,7 +552,7 @@ class BaseObservabilityManagerTest {
// Assert
coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) }
- coVerify(exactly = 1) { mockPendingMetricsManager.removePendingMetrics(listOf("id1")) }
+ coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() }
}
// endregion
@@ -603,7 +563,7 @@ class BaseObservabilityManagerTest {
fun `dispatchUnsentMetrics does nothing when no pending metrics exist`() =
runTest {
// Arrange
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns emptyList()
+ coEvery { mockPendingMetricsManager.hasPendingMetrics() } returns false
val manager = createManager()
@@ -613,6 +573,7 @@ class BaseObservabilityManagerTest {
// Assert
coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) }
coVerify(exactly = 0) { mockPendingMetricsManager.removePendingMetrics(any()) }
+ coVerify(exactly = 0) { mockPendingMetricsManager.getPendingMetricIds(any()) }
coVerify(exactly = 0) { mockSessionManager.getSessionsWithMetrics(any()) }
}
@@ -621,7 +582,7 @@ class BaseObservabilityManagerTest {
runTest {
// Arrange
val pendingIds = listOf("metric-1", "metric-2")
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns pendingIds
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(pendingIds, emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(pendingIds) } returns emptyList()
val manager = createManager()
@@ -630,7 +591,7 @@ class BaseObservabilityManagerTest {
manager.dispatchUnsentMetrics()
// Assert
- coVerify(exactly = 1) { mockPendingMetricsManager.getAllPendingMetricIds() }
+ coVerify(exactly = 2) { mockPendingMetricsManager.getPendingMetricIds(any()) }
coVerify(exactly = 1) { mockSessionManager.getSessionsWithMetrics(pendingIds) }
}
@@ -645,7 +606,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(metric1)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1", "orphaned-id")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1", "orphaned-id"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -669,7 +630,7 @@ class BaseObservabilityManagerTest {
fun `dispatchUnsentMetrics cleans up all orphaned pending IDs when no sessions match`() =
runTest {
// Arrange - all pending IDs are orphaned
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("orphan-1", "orphan-2")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("orphan-1", "orphan-2"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns emptyList()
val removedIds = mutableListOf()
@@ -707,7 +668,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(metric3)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1", "id2", "id3")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1", "id2", "id3"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session1, session2)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -741,7 +702,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(metric1, metric2)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1", "id2")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1", "id2"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.NonRetryableFailure("HTTP 400")
@@ -772,7 +733,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(metric1, metric2)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1", "id2")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1", "id2"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.RetryableFailure()
@@ -785,6 +746,573 @@ class BaseObservabilityManagerTest {
coVerify(exactly = 0) { mockPendingMetricsManager.removePendingMetrics(any()) }
}
+ @Test
+ fun `dispatchUnsentMetrics dispatches a backlog in successive chunks`() =
+ runTest {
+ val pendingIds = mutableListOf("metric-1", "metric-2", "metric-3")
+ val requestedChunks = mutableListOf>()
+ val removedIds = mutableListOf()
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } answers { pendingIds.take(2) }
+ coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers {
+ val ids = firstArg>()
+ requestedChunks.add(ids)
+ ids.map { id ->
+ createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
+ coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers {
+ val ids = firstArg>()
+ removedIds.addAll(ids)
+ pendingIds.removeAll(ids.toSet())
+ }
+
+ createManager(dispatchChunkSize = 2).dispatchUnsentMetrics()
+
+ assertEquals(
+ listOf(listOf("metric-1", "metric-2"), listOf("metric-3")),
+ requestedChunks
+ )
+ assertEquals(listOf("metric-1", "metric-2", "metric-3"), removedIds)
+ coVerify(exactly = 2) { mockEventDispatcher.dispatch(any()) }
+ coVerify(exactly = 3) { mockPendingMetricsManager.getPendingMetricIds(2) }
+ }
+
+ @Test
+ fun `dispatchUnsentMetrics stops reading chunks after cancellation`() =
+ runTest {
+ val pendingIds = mutableListOf("metric-1", "metric-2", "metric-3")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } answers { pendingIds.take(2) }
+ coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers {
+ firstArg>().map { id ->
+ createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
+ coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } coAnswers {
+ pendingIds.removeAll(firstArg>().toSet())
+ currentCoroutineContext().cancel()
+ }
+
+ launch {
+ createManager(dispatchChunkSize = 2).dispatchUnsentMetrics()
+ }.join()
+
+ assertEquals(listOf("metric-3"), pendingIds)
+ coVerify(exactly = 1) { mockPendingMetricsManager.getPendingMetricIds(2) }
+ }
+
+ @Test
+ fun `dispatchUnsentMetrics halves the chunk after 413 then resets to the default size`() =
+ runTest {
+ val requestedLimits = mutableListOf()
+ val defaultChunks = ArrayDeque(
+ listOf(
+ listOf("metric-1", "metric-2", "metric-3", "metric-4"),
+ listOf("metric-3", "metric-4"),
+ emptyList()
+ )
+ )
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(4) } answers {
+ requestedLimits.add(4)
+ defaultChunks.removeFirst()
+ }
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } answers {
+ requestedLimits.add(2)
+ listOf("metric-1", "metric-2")
+ }
+ coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers {
+ firstArg>().map { id ->
+ createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatch(any()) } returnsMany listOf(
+ DispatchResult.PayloadTooLarge,
+ DispatchResult.Success,
+ DispatchResult.Success
+ )
+ val removedChunks = mutableListOf>()
+ coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers {
+ removedChunks.add(firstArg>())
+ }
+
+ createManager(dispatchChunkSize = 4).dispatchUnsentMetrics()
+
+ assertEquals(listOf(4, 2, 4, 4), requestedLimits)
+ assertEquals(
+ listOf(listOf("metric-1", "metric-2"), listOf("metric-3", "metric-4")),
+ removedChunks
+ )
+ }
+
+ @Test
+ fun `dispatchUnsentMetrics halves the dispatched count after removing orphans`() =
+ runTest {
+ val requestedLimits = mutableListOf()
+ val defaultChunks = ArrayDeque(
+ listOf(
+ listOf("metric-1", "metric-2", "orphan-1", "orphan-2"),
+ emptyList()
+ )
+ )
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(4) } answers {
+ requestedLimits.add(4)
+ defaultChunks.removeFirst()
+ }
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(1) } answers {
+ requestedLimits.add(1)
+ listOf("metric-1")
+ }
+ coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers {
+ firstArg>().filter { it.startsWith("metric-") }.map { id ->
+ createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatch(any()) } returnsMany listOf(
+ DispatchResult.PayloadTooLarge,
+ DispatchResult.Success
+ )
+
+ createManager(dispatchChunkSize = 4).dispatchUnsentMetrics()
+
+ assertEquals(listOf(4, 1, 4), requestedLimits)
+ }
+
+ @Test
+ fun `dispatchUnsentMetrics drops a single metric that still gets 413`() =
+ runTest {
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(4) } returnsMany
+ listOf(listOf("metric-1"), emptyList())
+ coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(
+ createSessionWithMetrics(
+ "session-1",
+ "production",
+ listOf(createMetric("metric-1", metricId = "metric-1"))
+ )
+ )
+ coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.PayloadTooLarge
+
+ createManager(dispatchChunkSize = 4).dispatchUnsentMetrics()
+
+ coVerify(exactly = 1) { mockPendingMetricsManager.removePendingMetrics(listOf("metric-1")) }
+ coVerify(exactly = 1) { mockPendingMetricsManager.getPendingMetricIds(4) }
+ }
+
+ @Test
+ fun `dispatchUnsentMetrics halves to one then drops the oversized metric`() =
+ runTest {
+ val requestedLimits = mutableListOf()
+ val defaultChunks = ArrayDeque(
+ listOf(listOf("metric-1", "metric-2"), emptyList())
+ )
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } answers {
+ requestedLimits.add(2)
+ defaultChunks.removeFirst()
+ }
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(1) } answers {
+ requestedLimits.add(1)
+ listOf("metric-1")
+ }
+ coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers {
+ firstArg>().map { id ->
+ createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.PayloadTooLarge
+
+ createManager(dispatchChunkSize = 2).dispatchUnsentMetrics()
+
+ assertEquals(listOf(2, 1), requestedLimits)
+ coVerify(exactly = 1) { mockPendingMetricsManager.removePendingMetrics(listOf("metric-1")) }
+ }
+
+ @Test
+ fun `dispatchUnsentMetrics does not set the retry gate after 413`() =
+ runTest {
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } returnsMany
+ listOf(listOf("metric-1"), listOf("metric-2"), emptyList())
+ coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers {
+ firstArg>().map { id ->
+ createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatch(any()) } returnsMany listOf(
+ DispatchResult.PayloadTooLarge,
+ DispatchResult.Success
+ )
+ val manager = createManager(
+ currentTimeMs = { 1_700_000_000_000L },
+ dispatchChunkSize = 2
+ )
+
+ manager.dispatchUnsentMetrics()
+ manager.dispatchUnsentMetrics()
+
+ coVerify(exactly = 2) { mockEventDispatcher.dispatch(any()) }
+ }
+
+ @Test
+ fun `dispatchUnsentMetrics stops after a retryable chunk and sets the gate`() =
+ runTest {
+ val session = createSessionWithMetrics(
+ "session-1",
+ "production",
+ listOf(
+ createMetric("metric-1", metricId = "metric-1"),
+ createMetric("metric-2", metricId = "metric-2")
+ )
+ )
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } returns listOf("metric-1", "metric-2")
+ coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session)
+ coEvery { mockEventDispatcher.dispatch(any()) } returns
+ DispatchResult.RetryableFailure(retryAfterMs = 60_000L)
+
+ val manager = createManager(
+ currentTimeMs = { 1_700_000_000_000L },
+ dispatchChunkSize = 2
+ )
+ manager.dispatchUnsentMetrics()
+ manager.dispatchUnsentMetrics()
+
+ coVerify(exactly = 1) { mockPendingMetricsManager.getPendingMetricIds(2) }
+ coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) }
+ coVerify(exactly = 0) { mockPendingMetricsManager.removePendingMetrics(any()) }
+ }
+
+ @Test
+ fun `dispatchUnsentMetrics drops a non-retryable chunk and stops`() =
+ runTest {
+ val removedChunks = mutableListOf>()
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } returnsMany
+ listOf(listOf("metric-1", "metric-2"), listOf("metric-3"), emptyList())
+ coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers {
+ firstArg>().map { id ->
+ createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatch(any()) } returnsMany listOf(
+ DispatchResult.NonRetryableFailure("HTTP 400"),
+ DispatchResult.Success
+ )
+ coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers {
+ removedChunks.add(firstArg>())
+ }
+
+ createManager(dispatchChunkSize = 2).dispatchUnsentMetrics()
+
+ assertEquals(listOf(listOf("metric-1", "metric-2")), removedChunks)
+ coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) }
+ coVerify(exactly = 1) { mockPendingMetricsManager.getPendingMetricIds(2) }
+ }
+
+ @Test
+ fun `dispatchUnsentMetrics removes an orphaned chunk and continues`() =
+ runTest {
+ val pendingIds = mutableListOf("orphan-1", "metric-1", "metric-2")
+ val removedChunks = mutableListOf>()
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } answers { pendingIds.take(2) }
+ coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers {
+ firstArg>()
+ .filter { it.startsWith("metric-") }
+ .map { id ->
+ createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
+ coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers {
+ val ids = firstArg>()
+ removedChunks.add(ids)
+ pendingIds.removeAll(ids.toSet())
+ }
+
+ createManager(dispatchChunkSize = 2).dispatchUnsentMetrics()
+
+ assertEquals(
+ listOf(listOf("orphan-1"), listOf("metric-1"), listOf("metric-2")),
+ removedChunks
+ )
+ coVerify(exactly = 2) { mockEventDispatcher.dispatch(any()) }
+ }
+
+ @Test
+ fun `dispatchUnsentLogs dispatches a backlog in successive chunks`() =
+ runTest {
+ val pendingIds = mutableListOf("log-1", "log-2", "log-3")
+ val requestedChunks = mutableListOf>()
+ val removedIds = mutableListOf()
+ coEvery { mockPendingLogsManager.getPendingLogIds(2) } answers { pendingIds.take(2) }
+ coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers {
+ val ids = firstArg>()
+ requestedChunks.add(ids)
+ ids.map { id ->
+ createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.Success
+ coEvery { mockPendingLogsManager.removePendingLogs(any()) } answers {
+ val ids = firstArg>()
+ removedIds.addAll(ids)
+ pendingIds.removeAll(ids.toSet())
+ }
+
+ createManager(dispatchChunkSize = 2).dispatchUnsentLogs()
+
+ assertEquals(listOf(listOf("log-1", "log-2"), listOf("log-3")), requestedChunks)
+ assertEquals(listOf("log-1", "log-2", "log-3"), removedIds)
+ coVerify(exactly = 2) { mockEventDispatcher.dispatchLogs(any()) }
+ coVerify(exactly = 3) { mockPendingLogsManager.getPendingLogIds(2) }
+ }
+
+ @Test
+ fun `dispatchUnsentLogs stops reading chunks after cancellation`() =
+ runTest {
+ val pendingIds = mutableListOf("log-1", "log-2", "log-3")
+ coEvery { mockPendingLogsManager.getPendingLogIds(2) } answers { pendingIds.take(2) }
+ coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers {
+ firstArg>().map { id ->
+ createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.Success
+ coEvery { mockPendingLogsManager.removePendingLogs(any()) } coAnswers {
+ pendingIds.removeAll(firstArg>().toSet())
+ currentCoroutineContext().cancel()
+ }
+
+ launch {
+ createManager(dispatchChunkSize = 2).dispatchUnsentLogs()
+ }.join()
+
+ assertEquals(listOf("log-3"), pendingIds)
+ coVerify(exactly = 1) { mockPendingLogsManager.getPendingLogIds(2) }
+ }
+
+ @Test
+ fun `dispatchUnsentLogs halves the chunk after 413 then resets to the default size`() =
+ runTest {
+ val requestedLimits = mutableListOf()
+ val defaultChunks = ArrayDeque(
+ listOf(
+ listOf("log-1", "log-2", "log-3", "log-4"),
+ listOf("log-3", "log-4"),
+ emptyList()
+ )
+ )
+ coEvery { mockPendingLogsManager.getPendingLogIds(4) } answers {
+ requestedLimits.add(4)
+ defaultChunks.removeFirst()
+ }
+ coEvery { mockPendingLogsManager.getPendingLogIds(2) } answers {
+ requestedLimits.add(2)
+ listOf("log-1", "log-2")
+ }
+ coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers {
+ firstArg>().map { id ->
+ createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatchLogs(any()) } returnsMany listOf(
+ DispatchResult.PayloadTooLarge,
+ DispatchResult.Success,
+ DispatchResult.Success
+ )
+ val removedChunks = mutableListOf>()
+ coEvery { mockPendingLogsManager.removePendingLogs(any()) } answers {
+ removedChunks.add(firstArg>())
+ }
+
+ createManager(dispatchChunkSize = 4).dispatchUnsentLogs()
+
+ assertEquals(listOf(4, 2, 4, 4), requestedLimits)
+ assertEquals(listOf(listOf("log-1", "log-2"), listOf("log-3", "log-4")), removedChunks)
+ }
+
+ @Test
+ fun `dispatchUnsentLogs drops a single log that still gets 413`() =
+ runTest {
+ coEvery { mockPendingLogsManager.getPendingLogIds(4) } returnsMany
+ listOf(listOf("log-1"), emptyList())
+ coEvery { mockSessionManager.getSessionsWithLogs(any()) } returns listOf(
+ createSessionWithLogs(
+ "session-1",
+ "production",
+ listOf(createLog("log-1", logId = "log-1"))
+ )
+ )
+ coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.PayloadTooLarge
+
+ createManager(dispatchChunkSize = 4).dispatchUnsentLogs()
+
+ coVerify(exactly = 1) { mockPendingLogsManager.removePendingLogs(listOf("log-1")) }
+ coVerify(exactly = 1) { mockPendingLogsManager.getPendingLogIds(4) }
+ }
+
+ @Test
+ fun `dispatchUnsentLogs halves the dispatched count after removing orphans`() =
+ runTest {
+ val requestedLimits = mutableListOf()
+ val defaultChunks = ArrayDeque(
+ listOf(
+ listOf("log-1", "log-2", "orphan-1", "orphan-2"),
+ emptyList()
+ )
+ )
+ coEvery { mockPendingLogsManager.getPendingLogIds(4) } answers {
+ requestedLimits.add(4)
+ defaultChunks.removeFirst()
+ }
+ coEvery { mockPendingLogsManager.getPendingLogIds(1) } answers {
+ requestedLimits.add(1)
+ listOf("log-1")
+ }
+ coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers {
+ firstArg>().filter { it.startsWith("log-") }.map { id ->
+ createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatchLogs(any()) } returnsMany listOf(
+ DispatchResult.PayloadTooLarge,
+ DispatchResult.Success
+ )
+
+ createManager(dispatchChunkSize = 4).dispatchUnsentLogs()
+
+ assertEquals(listOf(4, 1, 4), requestedLimits)
+ }
+
+ @Test
+ fun `dispatchUnsentLogs halves to one then drops the oversized log`() =
+ runTest {
+ val requestedLimits = mutableListOf()
+ val defaultChunks = ArrayDeque(
+ listOf(listOf("log-1", "log-2"), emptyList())
+ )
+ coEvery { mockPendingLogsManager.getPendingLogIds(2) } answers {
+ requestedLimits.add(2)
+ defaultChunks.removeFirst()
+ }
+ coEvery { mockPendingLogsManager.getPendingLogIds(1) } answers {
+ requestedLimits.add(1)
+ listOf("log-1")
+ }
+ coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers {
+ firstArg>().map { id ->
+ createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.PayloadTooLarge
+
+ createManager(dispatchChunkSize = 2).dispatchUnsentLogs()
+
+ assertEquals(listOf(2, 1), requestedLimits)
+ coVerify(exactly = 1) { mockPendingLogsManager.removePendingLogs(listOf("log-1")) }
+ }
+
+ @Test
+ fun `dispatchUnsentLogs does not set the retry gate after 413`() =
+ runTest {
+ coEvery { mockPendingLogsManager.getPendingLogIds(2) } returnsMany
+ listOf(listOf("log-1"), listOf("log-2"), emptyList())
+ coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers {
+ firstArg>().map { id ->
+ createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatchLogs(any()) } returnsMany listOf(
+ DispatchResult.PayloadTooLarge,
+ DispatchResult.Success
+ )
+ val manager = createManager(
+ currentTimeMs = { 1_700_000_000_000L },
+ dispatchChunkSize = 2
+ )
+
+ manager.dispatchUnsentLogs()
+ manager.dispatchUnsentLogs()
+
+ coVerify(exactly = 2) { mockEventDispatcher.dispatchLogs(any()) }
+ }
+
+ @Test
+ fun `dispatchUnsentLogs stops after a retryable chunk and sets the gate`() =
+ runTest {
+ val session = createSessionWithLogs(
+ "session-1",
+ "production",
+ listOf(
+ createLog("log-1", logId = "log-1"),
+ createLog("log-2", logId = "log-2")
+ )
+ )
+ coEvery { mockPendingLogsManager.getPendingLogIds(2) } returns listOf("log-1", "log-2")
+ coEvery { mockSessionManager.getSessionsWithLogs(any()) } returns listOf(session)
+ coEvery { mockEventDispatcher.dispatchLogs(any()) } returns
+ DispatchResult.RetryableFailure(retryAfterMs = 60_000L)
+
+ val manager = createManager(
+ currentTimeMs = { 1_700_000_000_000L },
+ dispatchChunkSize = 2
+ )
+ manager.dispatchUnsentLogs()
+ manager.dispatchUnsentLogs()
+
+ coVerify(exactly = 1) { mockPendingLogsManager.getPendingLogIds(2) }
+ coVerify(exactly = 1) { mockEventDispatcher.dispatchLogs(any()) }
+ coVerify(exactly = 0) { mockPendingLogsManager.removePendingLogs(any()) }
+ }
+
+ @Test
+ fun `dispatchUnsentLogs drops a non-retryable chunk and stops`() =
+ runTest {
+ val removedChunks = mutableListOf>()
+ coEvery { mockPendingLogsManager.getPendingLogIds(2) } returnsMany
+ listOf(listOf("log-1", "log-2"), listOf("log-3"), emptyList())
+ coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers {
+ firstArg>().map { id ->
+ createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatchLogs(any()) } returnsMany listOf(
+ DispatchResult.NonRetryableFailure("HTTP 400"),
+ DispatchResult.Success
+ )
+ coEvery { mockPendingLogsManager.removePendingLogs(any()) } answers {
+ removedChunks.add(firstArg>())
+ }
+
+ createManager(dispatchChunkSize = 2).dispatchUnsentLogs()
+
+ assertEquals(listOf(listOf("log-1", "log-2")), removedChunks)
+ coVerify(exactly = 1) { mockEventDispatcher.dispatchLogs(any()) }
+ coVerify(exactly = 1) { mockPendingLogsManager.getPendingLogIds(2) }
+ }
+
+ @Test
+ fun `dispatchUnsentLogs removes an orphaned chunk and continues`() =
+ runTest {
+ val pendingIds = mutableListOf("orphan-1", "log-1", "log-2")
+ val removedChunks = mutableListOf>()
+ coEvery { mockPendingLogsManager.getPendingLogIds(2) } answers { pendingIds.take(2) }
+ coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers {
+ firstArg>()
+ .filter { it.startsWith("log-") }
+ .map { id ->
+ createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id)))
+ }
+ }
+ coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.Success
+ coEvery { mockPendingLogsManager.removePendingLogs(any()) } answers {
+ val ids = firstArg>()
+ removedChunks.add(ids)
+ pendingIds.removeAll(ids.toSet())
+ }
+
+ createManager(dispatchChunkSize = 2).dispatchUnsentLogs()
+
+ assertEquals(listOf(listOf("orphan-1"), listOf("log-1"), listOf("log-2")), removedChunks)
+ coVerify(exactly = 2) { mockEventDispatcher.dispatchLogs(any()) }
+ }
+
// endregion
// region Cleanup tests
@@ -961,7 +1489,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(metric1, metric2)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("m1", "m2")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("m1", "m2"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -1005,7 +1533,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(metric2)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("metric-id-1", "metric-id-2")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("metric-id-1", "metric-id-2"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session1, session2)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -1054,7 +1582,7 @@ class BaseObservabilityManagerTest {
metrics = listOf(metric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session)
coEvery { mockEventDispatcher.dispatch(any()) } returns
DispatchResult.RetryableFailure(retryAfterMs = 60_000L)
@@ -1089,12 +1617,12 @@ class BaseObservabilityManagerTest {
logs = listOf(logRecord)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(metricSession)
coEvery { mockEventDispatcher.dispatch(any()) } returns
DispatchResult.RetryableFailure(retryAfterMs = 60_000L)
- coEvery { mockPendingLogsManager.getAllPendingLogIds() } returns listOf("log-1")
+ coEvery { mockPendingLogsManager.getPendingLogIds(any()) } returnsMany listOf(listOf("log-1"), emptyList())
coEvery { mockSessionManager.getSessionsWithLogs(any()) } returns listOf(logSession)
coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.Success
@@ -1125,12 +1653,12 @@ class BaseObservabilityManagerTest {
logs = listOf(logRecord)
)
- coEvery { mockPendingLogsManager.getAllPendingLogIds() } returns listOf("log-1")
+ coEvery { mockPendingLogsManager.getPendingLogIds(any()) } returnsMany listOf(listOf("log-1"), emptyList())
coEvery { mockSessionManager.getSessionsWithLogs(any()) } returns listOf(logSession)
coEvery { mockEventDispatcher.dispatchLogs(any()) } returns
DispatchResult.RetryableFailure(retryAfterMs = 60_000L)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(metricSession)
coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success
@@ -1155,7 +1683,8 @@ class BaseObservabilityManagerTest {
metrics = listOf(metric)
)
- coEvery { mockPendingMetricsManager.getAllPendingMetricIds() } returns listOf("id1")
+ coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany
+ listOf(listOf("id1"), listOf("id1"), emptyList())
coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session)
coEvery { mockEventDispatcher.dispatch(any()) } returnsMany listOf(
DispatchResult.RetryableFailure(retryAfterMs = 60_000L),
@@ -1179,7 +1708,8 @@ class BaseObservabilityManagerTest {
private fun createManager(
isDebugBuild: Boolean = false,
deterministicUniformValue: Double = 0.0,
- currentTimeMs: () -> Long = { TimeUtils.getWallClockMillis() }
+ currentTimeMs: () -> Long = { TimeUtils.getWallClockMillis() },
+ dispatchChunkSize: Int = DISPATCH_CHUNK_SIZE
): BaseObservabilityManager {
val manager = BaseObservabilityManager(
context = mockContext,
@@ -1190,7 +1720,8 @@ class BaseObservabilityManagerTest {
baseUrl = testBaseUrl,
isDebugBuild = isDebugBuild,
deterministicUniformValueProvider = { deterministicUniformValue },
- currentTimeMs = currentTimeMs
+ currentTimeMs = currentTimeMs,
+ dispatchChunkSize = dispatchChunkSize
)
// Replace the internal EventDispatcher with our mock
val field = BaseObservabilityManager::class.java.getDeclaredField("eventDispatcher")
diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsRetryGateTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsRetryGateTest.kt
index 5f101feea9a7db..cd1e46ea2b0a85 100644
--- a/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsRetryGateTest.kt
+++ b/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsRetryGateTest.kt
@@ -74,6 +74,26 @@ class DispatchUtilsRetryGateTest {
assertEquals(state.dispatchAfterMs, next.dispatchAfterMs)
}
+ // `PayloadTooLarge` is a definitive answer from a reachable server, so the counter resets
+ // like it does for `NonRetryableFailure`; the gate stays where it is because chunk-size
+ // retries are immediate.
+ @Test
+ fun `PayloadTooLarge resets the counter and leaves the gate alone`() {
+ val state = DispatchUtils.RetryGateState(
+ dispatchAfterMs = now + 60_000L,
+ consecutiveRetryableFailures = 2
+ )
+ val next = DispatchUtils.nextRetryGateState(
+ result = DispatchResult.PayloadTooLarge,
+ currentState = state,
+ now = now,
+ backoff = stubbedBackoff
+ )
+
+ assertEquals(0, next.consecutiveRetryableFailures)
+ assertEquals(state.dispatchAfterMs, next.dispatchAfterMs)
+ }
+
// First retryable failure (from `.initial`): counter goes to 1, gate is `now + backoff(1)`.
// `retryAfterMs` is `null`, so we fall through to `computeBackoffDelay` (the stubbed value
// of 10 here).
diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsTest.kt
index a55271b267b83f..1e669197eddaf2 100644
--- a/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsTest.kt
+++ b/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsTest.kt
@@ -162,6 +162,19 @@ class DispatchUtilsClassifyResponseTest {
}
}
+ @Test
+ fun `413 returns PayloadTooLarge with or without Retry-After`() {
+ for (retryAfter in listOf(null, "120")) {
+ val result = DispatchUtils.classifyResponse(
+ statusCode = 413,
+ retryAfterHeader = retryAfter,
+ responseBody = null
+ )
+
+ assertEquals(DispatchResult.PayloadTooLarge, result)
+ }
+ }
+
// MARK: -- Non-retryable 4xx / other 5xx
@Test
@@ -252,6 +265,13 @@ class DispatchUtilsShouldRemovePendingTest {
assertTrue(DispatchUtils.shouldRemovePending(DispatchResult.Success))
}
+ // Multi-record payloads are retried in smaller chunks before this check, so a 413 here
+ // represents a single oversized record that must not wedge the queue.
+ @Test
+ fun `PayloadTooLarge removes pending IDs`() {
+ assertTrue(DispatchUtils.shouldRemovePending(DispatchResult.PayloadTooLarge))
+ }
+
// `Retryable` is the "leave them alone" case — the next dispatch round picks the same
// rows up again. This is what keeps an in-flight outage from losing telemetry.
@Test
diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/EventDispatcherTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/EventDispatcherTest.kt
index a3413b573701e8..c37d5175a1d97b 100644
--- a/packages/expo-observe/android/src/test/java/expo/modules/observe/EventDispatcherTest.kt
+++ b/packages/expo-observe/android/src/test/java/expo/modules/observe/EventDispatcherTest.kt
@@ -139,6 +139,21 @@ class EventDispatcherTest {
assertEquals(1, mockServer.requestCount)
}
+ @Test
+ fun `dispatch returns PayloadTooLarge on 413 response`() =
+ runTest {
+ mockServer.enqueue(
+ MockResponse()
+ .setResponseCode(413)
+ .setBody("""{"error": "Payload Too Large"}""")
+ )
+
+ val result = eventDispatcher.dispatch(listOf(createTestEvent()))
+
+ assertEquals(DispatchResult.PayloadTooLarge, result)
+ assertEquals(1, mockServer.requestCount)
+ }
+
@Test
fun `dispatch returns NonRetryable on 500 server error`() =
runTest {
diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingLogsManagerTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingLogsManagerTest.kt
new file mode 100644
index 00000000000000..12b6718ac8998b
--- /dev/null
+++ b/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingLogsManagerTest.kt
@@ -0,0 +1,156 @@
+package expo.modules.observe.storage
+
+import android.content.Context
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [28])
+class PendingLogsManagerTest {
+ private lateinit var database: ObserveDatabase
+ private lateinit var manager: PendingLogsManager
+
+ @Before
+ fun setUp() {
+ val context = ApplicationProvider.getApplicationContext()
+ database = Room
+ .inMemoryDatabaseBuilder(context, ObserveDatabase::class.java)
+ .allowMainThreadQueries()
+ .build()
+ manager = PendingLogsManager(context, database)
+ }
+
+ @After
+ fun tearDown() {
+ database.close()
+ }
+
+ @Test
+ fun `addPendingLogs inserts logs correctly`() =
+ runTest {
+ val logIds = listOf("log-1", "log-2", "log-3")
+
+ manager.addPendingLogs(logIds)
+
+ val result = manager.getPendingLogIds(Int.MAX_VALUE)
+ assertEquals(3, result.size)
+ assertTrue(result.containsAll(logIds))
+ }
+
+ @Test
+ fun `getPendingLogIds returns all inserted IDs from multiple add calls`() =
+ runTest {
+ manager.addPendingLogs(listOf("log-1", "log-2"))
+ manager.addPendingLogs(listOf("log-3"))
+
+ val result = manager.getPendingLogIds(Int.MAX_VALUE)
+
+ assertEquals(3, result.size)
+ assertTrue(result.containsAll(listOf("log-1", "log-2", "log-3")))
+ }
+
+ @Test
+ fun `getPendingLogIds returns the oldest IDs up to the limit`() =
+ runTest {
+ database.pendingLogDao().insertAll(
+ listOf(
+ PendingLog("log-3", "2025-01-03T00:00:00.000Z"),
+ PendingLog("log-1", "2025-01-01T00:00:00.000Z"),
+ PendingLog("log-4", "2025-01-04T00:00:00.000Z"),
+ PendingLog("log-2", "2025-01-02T00:00:00.000Z")
+ )
+ )
+
+ val result = manager.getPendingLogIds(2)
+
+ assertEquals(listOf("log-1", "log-2"), result)
+ }
+
+ @Test
+ fun `hasPendingLogs reflects whether logs are pending`() =
+ runTest {
+ assertFalse(manager.hasPendingLogs())
+
+ manager.addPendingLogs(listOf("log-1"))
+
+ assertTrue(manager.hasPendingLogs())
+ }
+
+ @Test
+ fun `removePendingLogs deletes specified IDs only`() =
+ runTest {
+ manager.addPendingLogs(listOf("log-1", "log-2", "log-3"))
+
+ manager.removePendingLogs(listOf("log-1", "log-3"))
+
+ assertEquals(listOf("log-2"), manager.getPendingLogIds(Int.MAX_VALUE))
+ }
+
+ @Test
+ fun `cleanupOldPendingLogs removes old entries`() =
+ runTest {
+ database.pendingLogDao().insertAll(
+ listOf(PendingLog(logId = "old-log", addedAt = "2020-01-01T00:00:00.000Z"))
+ )
+ manager.addPendingLogs(listOf("recent-log"))
+
+ manager.cleanupOldPendingLogs()
+
+ assertEquals(listOf("recent-log"), manager.getPendingLogIds(Int.MAX_VALUE))
+ }
+
+ @Test
+ fun `addPendingLogs with duplicate IDs ignores duplicates`() =
+ runTest {
+ manager.addPendingLogs(listOf("log-1", "log-2"))
+
+ manager.addPendingLogs(listOf("log-2", "log-3"))
+
+ val result = manager.getPendingLogIds(Int.MAX_VALUE)
+ assertEquals(3, result.size)
+ assertTrue(result.containsAll(listOf("log-1", "log-2", "log-3")))
+ }
+
+ @Test
+ fun `removePendingLogs with empty list is a no-op`() =
+ runTest {
+ manager.addPendingLogs(listOf("log-1", "log-2"))
+
+ manager.removePendingLogs(emptyList())
+
+ assertEquals(2, manager.getPendingLogIds(Int.MAX_VALUE).size)
+ }
+
+ @Test
+ fun `removeAllPendingLogs deletes all pending logs`() =
+ runTest {
+ manager.addPendingLogs(listOf("log-1", "log-2"))
+
+ manager.removeAllPendingLogs()
+
+ assertFalse(manager.hasPendingLogs())
+ }
+
+ @Test
+ fun `removePendingLogs handles more than 900 items`() =
+ runTest {
+ val allIds = (1..1100).map { "log-$it" }
+ allIds.chunked(500).forEach { chunk ->
+ manager.addPendingLogs(chunk)
+ }
+ assertEquals(1100, manager.getPendingLogIds(Int.MAX_VALUE).size)
+
+ manager.removePendingLogs(allIds)
+
+ val remaining = manager.getPendingLogIds(Int.MAX_VALUE)
+ assertTrue("Expected empty but got ${remaining.size} items", remaining.isEmpty())
+ }
+}
diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingMetricsManagerTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingMetricsManagerTest.kt
index 3a2c5712bee0ca..84b698b0ab72aa 100644
--- a/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingMetricsManagerTest.kt
+++ b/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingMetricsManagerTest.kt
@@ -43,26 +43,43 @@ class PendingMetricsManagerTest {
manager.addPendingMetrics(metricIds)
// Assert
- val result = manager.getAllPendingMetricIds()
+ val result = manager.getPendingMetricIds(Int.MAX_VALUE)
assertEquals(3, result.size)
assertTrue(result.containsAll(metricIds))
}
@Test
- fun `getAllPendingMetricIds returns all inserted IDs from multiple add calls`() =
+ fun `getPendingMetricIds returns all inserted IDs from multiple add calls`() =
runTest {
// Arrange
manager.addPendingMetrics(listOf("metric-1", "metric-2"))
manager.addPendingMetrics(listOf("metric-3"))
// Act
- val result = manager.getAllPendingMetricIds()
+ val result = manager.getPendingMetricIds(Int.MAX_VALUE)
// Assert
assertEquals(3, result.size)
assertTrue(result.containsAll(listOf("metric-1", "metric-2", "metric-3")))
}
+ @Test
+ fun `getPendingMetricIds returns the oldest IDs up to the limit`() =
+ runTest {
+ database.pendingMetricDao().insertAll(
+ listOf(
+ PendingMetric("metric-3", "2025-01-03T00:00:00.000Z"),
+ PendingMetric("metric-1", "2025-01-01T00:00:00.000Z"),
+ PendingMetric("metric-4", "2025-01-04T00:00:00.000Z"),
+ PendingMetric("metric-2", "2025-01-02T00:00:00.000Z")
+ )
+ )
+
+ val result = manager.getPendingMetricIds(2)
+
+ assertEquals(listOf("metric-1", "metric-2"), result)
+ }
+
@Test
fun `removePendingMetrics deletes specified IDs only`() =
runTest {
@@ -73,7 +90,7 @@ class PendingMetricsManagerTest {
manager.removePendingMetrics(listOf("metric-1", "metric-3"))
// Assert
- val remaining = manager.getAllPendingMetricIds()
+ val remaining = manager.getPendingMetricIds(Int.MAX_VALUE)
assertEquals(1, remaining.size)
assertEquals("metric-2", remaining[0])
}
@@ -89,13 +106,13 @@ class PendingMetricsManagerTest {
manager.addPendingMetrics(listOf("recent-metric"))
// Verify both exist
- assertEquals(2, manager.getAllPendingMetricIds().size)
+ assertEquals(2, manager.getPendingMetricIds(Int.MAX_VALUE).size)
// Act
manager.cleanupOldPendingMetrics()
// Assert - only recent metric survives
- val remaining = manager.getAllPendingMetricIds()
+ val remaining = manager.getPendingMetricIds(Int.MAX_VALUE)
assertEquals(1, remaining.size)
assertEquals("recent-metric", remaining[0])
}
@@ -110,7 +127,7 @@ class PendingMetricsManagerTest {
manager.addPendingMetrics(listOf("metric-2", "metric-3"))
// Assert - no duplicates
- val result = manager.getAllPendingMetricIds()
+ val result = manager.getPendingMetricIds(Int.MAX_VALUE)
assertEquals(3, result.size)
assertTrue(result.containsAll(listOf("metric-1", "metric-2", "metric-3")))
}
@@ -125,7 +142,17 @@ class PendingMetricsManagerTest {
manager.removePendingMetrics(emptyList())
// Assert - nothing removed
- assertEquals(2, manager.getAllPendingMetricIds().size)
+ assertEquals(2, manager.getPendingMetricIds(Int.MAX_VALUE).size)
+ }
+
+ @Test
+ fun `removeAllPendingMetrics deletes all pending metrics`() =
+ runTest {
+ manager.addPendingMetrics(listOf("metric-1", "metric-2"))
+
+ manager.removeAllPendingMetrics()
+
+ assertFalse(manager.hasPendingMetrics())
}
@Test
@@ -136,13 +163,13 @@ class PendingMetricsManagerTest {
allIds.chunked(500).forEach { chunk ->
manager.addPendingMetrics(chunk)
}
- assertEquals(1100, manager.getAllPendingMetricIds().size)
+ assertEquals(1100, manager.getPendingMetricIds(Int.MAX_VALUE).size)
// Act - remove all 1100 at once
manager.removePendingMetrics(allIds)
// Assert - all removed
- val remaining = manager.getAllPendingMetricIds()
+ val remaining = manager.getPendingMetricIds(Int.MAX_VALUE)
assertTrue("Expected empty but got ${remaining.size} items", remaining.isEmpty())
}
}
diff --git a/packages/expo/CHANGELOG.md b/packages/expo/CHANGELOG.md
index 68eb4f71075cd2..af07beada2dbd3 100644
--- a/packages/expo/CHANGELOG.md
+++ b/packages/expo/CHANGELOG.md
@@ -21,6 +21,7 @@
- [iOS] Add ExpoBundleConfiguration to derive RCTBundleConfiguration from the normalized bundle URL instead of default shared settings singleton ([#48010](https://github.com/expo/expo/pull/48010) by [@kitten](https://github.com/kitten))
- [iOS] Resolve the dev server port from the `RCTMetroPort` Info.plist key at runtime so bare projects without expo-dev-client connect to their own Metro instance instead of defaulting to 8081. ([#48098](https://github.com/expo/expo/pull/48098) by [@alanjhughes](https://github.com/alanjhughes))
- Fix async imports (`import(...)`) via `asyncRequireModule` not a thenable instead of a full promise shape ([#48550](https://github.com/expo/expo/pull/48550) by [@kitten](https://github.com/kitten))
+- Fix `window.location` being called regardless of `@expo/metro-runtime` being present on native when an async chunk loads after Metro disconnects ([#48944](https://github.com/expo/expo/pull/48944) by [@expo-bot](https://github.com/expo-bot))
- Fix DOM components dropping prop updates that are emitted while the WebView is still loading. ([#48813](https://github.com/expo/expo/pull/48813) by [@expo-bot](https://github.com/expo-bot))
- Fix `import.meta.url` being `null` on web when `transform.inlineRequires` is enabled. ([#49045](https://github.com/expo/expo/pull/49045) by [@expo-bot](https://github.com/expo-bot))
diff --git a/packages/expo/src/async-require/__tests__/hmr.test.ios.ts b/packages/expo/src/async-require/__tests__/hmr.test.ios.ts
new file mode 100644
index 00000000000000..37fe49b3b65085
--- /dev/null
+++ b/packages/expo/src/async-require/__tests__/hmr.test.ios.ts
@@ -0,0 +1,51 @@
+import HMRClient from '../hmr';
+import { reload } from '../hmrUtils';
+
+const listeners: Record void)[]> = {};
+
+function emit(event: string, ...args: any[]) {
+ const handlers = listeners[event];
+ if (!handlers?.length) {
+ throw new Error(`No "${event}" handler was registered on the Metro HMR client`);
+ }
+ handlers.forEach((handler) => handler(...args));
+}
+
+jest.mock('../hmrUtils', () => ({
+ getConnectionError: jest.fn(() => 'Cannot connect to Expo CLI.'),
+ getFullBundlerUrl: jest.fn(() => 'http://localhost:8081/index.bundle?platform=ios'),
+ handleCompileError: jest.fn(),
+ hideLoading: jest.fn(),
+ reload: jest.fn(),
+ resetErrorOverlay: jest.fn(),
+ showLoading: jest.fn(),
+}));
+
+jest.mock('@expo/metro/metro-runtime/modules/HMRClient', () => ({
+ __esModule: true,
+ default: class {
+ on(event: string, handler: (...args: any[]) => void) {
+ (listeners[event] ??= []).push(handler);
+ }
+ send = jest.fn();
+ enable = jest.fn();
+ disable = jest.fn();
+ close = jest.fn();
+ isEnabled = jest.fn(() => true);
+ hasPendingUpdates = jest.fn(() => false);
+ },
+}));
+
+it('reloads through the platform reload helper when an async bundle is registered after Metro disconnected', () => {
+ HMRClient.setup('ios', 'index.bundle', 'localhost', 8081, true, 'http');
+
+ // Metro's socket closes, which sets `hmrUnavailableReason`.
+ emit('close', { code: 1006, reason: 'connection failed' });
+
+ // An async chunk finishes loading afterwards. On native there is no
+ // `window.location` unless `@expo/metro-runtime` is installed, so this must
+ // not go through `window.location.reload()`.
+ HMRClient.registerBundle('http://localhost:8081/AsyncScreen.bundle?platform=ios');
+
+ expect(reload).toHaveBeenCalled();
+});
diff --git a/packages/expo/src/async-require/hmr.ts b/packages/expo/src/async-require/hmr.ts
index ae6371300df971..7b2687da9386cb 100644
--- a/packages/expo/src/async-require/hmr.ts
+++ b/packages/expo/src/async-require/hmr.ts
@@ -17,6 +17,7 @@ import {
getFullBundlerUrl,
handleCompileError,
hideLoading,
+ reload,
resetErrorOverlay,
showLoading,
} from './hmrUtils';
@@ -324,7 +325,7 @@ function setHMRUnavailableReason(reason: string) {
function registerBundleEntryPoints(client: MetroHMRClient | null) {
if (hmrUnavailableReason != null) {
// "Bundle Splitting – Metro disconnected"
- window.location.reload();
+ reload();
return;
}
diff --git a/packages/patch-project/CHANGELOG.md b/packages/patch-project/CHANGELOG.md
index 0779bd55af0090..b1310e2d51d9e6 100644
--- a/packages/patch-project/CHANGELOG.md
+++ b/packages/patch-project/CHANGELOG.md
@@ -8,6 +8,9 @@
### 🐛 Bug fixes
+- Use development mode when loading Expo config and `.env` files. ([#48882](https://github.com/expo/expo/pull/48882) by [@ramonclaudio](https://github.com/ramonclaudio))
+- Skip applying a CNG patch that is already applied to the native project, e.g. when running `npx expo prebuild --no-clean` more than once. ([#47605](https://github.com/expo/expo/issues/47605) by [@MUSE-CODE-SPACE](https://github.com/MUSE-CODE-SPACE))
+
### 💡 Others
## 57.0.9 - 2026-07-29
diff --git a/packages/patch-project/src/__tests__/gitPatch-test.ts b/packages/patch-project/src/__tests__/gitPatch-test.ts
index dc7573cbb4c442..1195530549043f 100644
--- a/packages/patch-project/src/__tests__/gitPatch-test.ts
+++ b/packages/patch-project/src/__tests__/gitPatch-test.ts
@@ -1,6 +1,6 @@
import spawnAsync from '@expo/spawn-async';
-import { applyPatchAsync, getPatchChangedLinesAsync } from '../gitPatch';
+import { applyPatchAsync, getPatchChangedLinesAsync, isPatchAppliedAsync } from '../gitPatch';
jest.mock('@expo/spawn-async');
jest.mock('fs');
@@ -23,6 +23,34 @@ describe(applyPatchAsync, () => {
});
});
+describe(isPatchAppliedAsync, () => {
+ it('should return true when the patch reverse-applies cleanly', async () => {
+ // @ts-expect-error
+ mockedSpawnAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
+ await expect(isPatchAppliedAsync('/app', '/app/cng-patches/ios+.patch')).resolves.toBe(true);
+ expect(mockedSpawnAsync).toHaveBeenCalledWith(
+ 'git',
+ ['apply', '--reverse', '--check', '--ignore-whitespace', '/app/cng-patches/ios+.patch'],
+ { cwd: '/app' }
+ );
+ });
+
+ it('should return false when the patch is not applied', async () => {
+ mockedSpawnAsync.mockRejectedValueOnce(new Error('error: patch does not apply'));
+ await expect(isPatchAppliedAsync('/app', '/app/cng-patches/ios+.patch')).resolves.toBe(false);
+ });
+
+ it('should throw if git is not installed', async () => {
+ const error = new Error('spawn git ENOENT');
+ // @ts-expect-error: Simulate spawn error
+ error.code = 'ENOENT';
+ mockedSpawnAsync.mockRejectedValueOnce(error);
+ await expect(() => isPatchAppliedAsync('/app', '/app/cng-patches/ios+.patch')).rejects.toThrow(
+ /Git is required to apply patches/
+ );
+ });
+});
+
describe(getPatchChangedLinesAsync, () => {
it('should return changed lines', async () => {
const mockPatchContent = `\
diff --git a/packages/patch-project/src/cli/__tests__/patchProjectAsync-test.ts b/packages/patch-project/src/cli/__tests__/patchProjectAsync-test.ts
new file mode 100644
index 00000000000000..83ea4a7f0513b2
--- /dev/null
+++ b/packages/patch-project/src/cli/__tests__/patchProjectAsync-test.ts
@@ -0,0 +1,58 @@
+import { loadProjectEnv, logLoadedEnv } from '@expo/env';
+import { getConfig } from 'expo/config';
+
+import { patchProjectAsync } from '../patchProjectAsync';
+
+jest.mock('@expo/env', () => ({
+ ...jest.requireActual('@expo/env'),
+ loadProjectEnv: jest.fn(),
+ logLoadedEnv: jest.fn(),
+}));
+jest.mock('expo/config', () => ({
+ getConfig: jest.fn(),
+}));
+jest.mock('../resolveFromExpoCli', () => ({
+ resolveFromExpoCli: jest.fn(() => 'patch-project-resolve-options'),
+}));
+jest.mock(
+ 'patch-project-resolve-options',
+ () => ({
+ ensureValidPlatforms: jest.fn(() => []),
+ }),
+ { virtual: true }
+);
+
+describe(patchProjectAsync, () => {
+ const devGlobal = globalThis as typeof globalThis & { __DEV__?: boolean };
+ const originalDev = devGlobal.__DEV__;
+ const originalConfigMode = process.env.__EXPO_CONFIG_MODE;
+
+ beforeEach(() => {
+ process.env.__EXPO_CONFIG_MODE = 'production';
+ });
+
+ afterEach(() => {
+ devGlobal.__DEV__ = originalDev;
+ if (originalConfigMode === undefined) {
+ delete process.env.__EXPO_CONFIG_MODE;
+ } else {
+ process.env.__EXPO_CONFIG_MODE = originalConfigMode;
+ }
+ });
+
+ it('loads and logs development env before Expo config', async () => {
+ const envInfo = { result: 'skipped' as const, loaded: [] };
+ jest.mocked(loadProjectEnv).mockReturnValue(envInfo);
+ jest.mocked(getConfig).mockReturnValue({ exp: {} } as ReturnType);
+
+ await patchProjectAsync('/app', { platforms: [] });
+
+ expect(loadProjectEnv).toHaveBeenCalledWith('/app', { mode: 'development' });
+ expect(logLoadedEnv).toHaveBeenCalledWith(envInfo);
+ expect(jest.mocked(loadProjectEnv).mock.invocationCallOrder[0]).toBeLessThan(
+ jest.mocked(getConfig).mock.invocationCallOrder[0]!
+ );
+ expect(devGlobal.__DEV__).toBe(true);
+ expect(process.env.__EXPO_CONFIG_MODE).toBeUndefined();
+ });
+});
diff --git a/packages/patch-project/src/cli/patchProjectAsync.ts b/packages/patch-project/src/cli/patchProjectAsync.ts
index 530f3da34cb8df..7d154e47c23913 100644
--- a/packages/patch-project/src/cli/patchProjectAsync.ts
+++ b/packages/patch-project/src/cli/patchProjectAsync.ts
@@ -1,3 +1,4 @@
+import { consumeConfigEnvMode, loadProjectEnv, logLoadedEnv } from '@expo/env';
import chalk from 'chalk';
import { getConfig, type ExpoConfig } from 'expo/config';
import { type ModPlatform } from 'expo/config-plugins';
@@ -16,6 +17,10 @@ import {
import { resolveFromExpoCli } from './resolveFromExpoCli';
import { createWorkingDirectoriesAsync, type WorkingDirectories } from './workingDirectories';
+declare namespace globalThis {
+ let __DEV__: boolean | undefined;
+}
+
const debug = require('debug')('patch-project') as typeof console.log;
/**
@@ -39,12 +44,10 @@ export async function patchProjectAsync(
const { ensureValidPlatforms } = require(
resolveFromExpoCli(projectRoot, 'build/src/prebuild/resolveOptions')
) as typeof import('@expo/cli/src/prebuild/resolveOptions');
- const { setNodeEnv } = require(
- resolveFromExpoCli(projectRoot, 'build/src/utils/nodeEnv')
- ) as typeof import('@expo/cli/src/utils/nodeEnv');
-
- setNodeEnv('development');
- require('@expo/env').load(projectRoot);
+ consumeConfigEnvMode();
+ globalThis.__DEV__ = true;
+ const envInfo = loadProjectEnv(projectRoot, { mode: 'development' });
+ logLoadedEnv(envInfo);
const { exp } = await getConfig(projectRoot);
const patchRoot = options.patchRoot || 'cng-patches';
diff --git a/packages/patch-project/src/gitPatch.ts b/packages/patch-project/src/gitPatch.ts
index 9c3a4445b7f33f..dd96d1a7f0d7c6 100644
--- a/packages/patch-project/src/gitPatch.ts
+++ b/packages/patch-project/src/gitPatch.ts
@@ -56,6 +56,23 @@ export async function applyPatchAsync(projectRoot: string, patchFilePath: string
return await runGitAsync(['apply', '--ignore-whitespace', patchFilePath], { cwd: projectRoot });
}
+export async function isPatchAppliedAsync(
+ projectRoot: string,
+ patchFilePath: string
+): Promise {
+ try {
+ await runGitAsync(['apply', '--reverse', '--check', '--ignore-whitespace', patchFilePath], {
+ cwd: projectRoot,
+ });
+ return true;
+ } catch (e: any) {
+ if (e.code === 'ENOENT') {
+ throw e;
+ }
+ return false;
+ }
+}
+
export async function getPatchChangedLinesAsync(patchFilePath: string): Promise {
const stdout = await runGitAsync(['apply', '--numstat', patchFilePath]);
const lines = stdout.split(/\r?\n/);
diff --git a/packages/patch-project/src/withPatchPlugin.ts b/packages/patch-project/src/withPatchPlugin.ts
index ebd58e6bfb998f..a7a102f4908cf3 100644
--- a/packages/patch-project/src/withPatchPlugin.ts
+++ b/packages/patch-project/src/withPatchPlugin.ts
@@ -9,7 +9,7 @@ import { glob as globAsync } from 'glob';
import path from 'path';
import * as env from './env';
-import { applyPatchAsync, getPatchChangedLinesAsync } from './gitPatch';
+import { applyPatchAsync, getPatchChangedLinesAsync, isPatchAppliedAsync } from './gitPatch';
const DEFAULT_PATCH_ROOT = 'cng-patches';
const DEFAULT_CHANGED_LINES_LIMIT = 300;
@@ -45,6 +45,15 @@ const withPatchMod: ConfigPlugin<{ platform: ModPlatform; props: PatchPluginProp
props
);
if (patchFilePath != null) {
+ // When prebuilding with `--no-clean`, the native project may already contain the patch
+ // from a previous run. Reapplying it would make `git apply` fail, so skip it.
+ if (await isPatchAppliedAsync(projectRoot, patchFilePath)) {
+ if (env.EXPO_DEBUG) {
+ console.log(`[withPatchPlugin] Patch is already applied, skipping: ${patchFilePath}`);
+ }
+ return config;
+ }
+
const changedLines = await getPatchChangedLinesAsync(patchFilePath);
const changedLinesLimit = props?.changedLinesLimit ?? DEFAULT_CHANGED_LINES_LIMIT;
if (changedLines > changedLinesLimit) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4fbe04f2e963af..3bc679475cdc53 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2652,8 +2652,8 @@ importers:
specifier: ^1.30.1
version: 1.32.0
noxcturnal:
- specifier: 0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc
- version: 0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc
+ specifier: ^0.1.0
+ version: 0.1.0
picomatch:
specifier: ^4.0.4
version: 4.0.4
@@ -8218,54 +8218,54 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
- '@noxcturnal/noxcturnal-darwin-arm64@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
- resolution: {integrity: sha512-jxiuwa80/CjzlMcWsXyvMiP17M0/F00v+fl3pbF5oPB5kc8oyB3B9cC9jdq1zEpkwl//EgceNMOybD49nhYbyA==}
+ '@noxcturnal/noxcturnal-darwin-arm64@0.1.0':
+ resolution: {integrity: sha512-ne7o6o2S0MVvifcqqg5pOA7oHAJ9jFl9jztvC2qV6Tn2PrYEcCgy3hy2o43U2ibRb50rhxPmk5BxTdWLQTN+2Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
- '@noxcturnal/noxcturnal-darwin-x64@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
- resolution: {integrity: sha512-lPEt5SnKC+A0sKpMTQ0VdDKk7tXSqteewasAiJJuuNvcdt7OWz+wYjy2Q5IVhpWNhSYKKiTLoBQhynX0bI9xzA==}
+ '@noxcturnal/noxcturnal-darwin-x64@0.1.0':
+ resolution: {integrity: sha512-/C83y+fUIh3j6bvM5ImHv+ZsfulFxBHdxqtSC+BTm+3i9Lhob4LKoE2vvxu1JOoNz+EUJsMnHXiGK3M5gWtg2A==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
- '@noxcturnal/noxcturnal-linux-arm64-gnu@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
- resolution: {integrity: sha512-sP7c5EDnCSRw5McqZVfmEd+zbULbZby0Bm/OwGUp0oTYMjYFHzn7DVrRCKDFk69JpKphV78THF9GpIM7zAMMcg==}
+ '@noxcturnal/noxcturnal-linux-arm64-gnu@0.1.0':
+ resolution: {integrity: sha512-eLFf4xeKDri/KFWj9aycsKeYSj3e5xLwAxU97BATICtOYc8TVy0hJVhpRSKI/ct+TshHK+RBLnlEK9SNqjA5Fw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@noxcturnal/noxcturnal-linux-arm64-musl@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
- resolution: {integrity: sha512-WgxQaZWZ2xAJB3vri3T7H+2lggLlmesjQ2b+JugRmWHmeDRgnm8+bO9pTTkdUYiwgJjtxgRbZsAdg96jXOyMXA==}
+ '@noxcturnal/noxcturnal-linux-arm64-musl@0.1.0':
+ resolution: {integrity: sha512-3EBJ+OuhPJ8+wN/XEvvHj7KeXRizNPvqE/Cqc/aMU1APpvfVq8zc6DSNrkd/p3tMws9MMLADek2xt8NXl0MxCg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@noxcturnal/noxcturnal-linux-x64-gnu@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
- resolution: {integrity: sha512-dEraHw0g9OHoqsROD6XZhZkr1bUA0nbwMXdovwo3xbtkAYc/ZuRofoz8+eM6g3wULphe0LauI8wDe8qPRGi7gA==}
+ '@noxcturnal/noxcturnal-linux-x64-gnu@0.1.0':
+ resolution: {integrity: sha512-Y/UkWoHT3DF//ctVETW2cnZzvILVXrabXEe8KbdA0Uz53AyAslHe/uHrDvcI42Fw9XFIuV26pvrn55T2IKhpaQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@noxcturnal/noxcturnal-linux-x64-musl@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
- resolution: {integrity: sha512-CqYPb/cSb3P+DxP7nuZ/MhH5Ta16QhR0+YqMpyeeRj1hkeDEF+xNtodvZJb9t1xw7JzWk/rfTg3DuzUzWsVE3g==}
+ '@noxcturnal/noxcturnal-linux-x64-musl@0.1.0':
+ resolution: {integrity: sha512-MZSkPghxh6k9zxsljYIZfQH0Q6YtOAmP2tZC1DMfWYGR2dNR9lyCxzJqYkiRpyND+y4CnjgPwuwZvKYHfcR6Sw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@noxcturnal/noxcturnal-win32-arm64-msvc@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
- resolution: {integrity: sha512-3VNTWxkFMu8+s4vWdFjWQIdcwEIo9/EJKcDPSNulKM5zOrMe35H883ht5KqUhAXnPaQ7/KGwdzd4l/bxxl45UA==}
+ '@noxcturnal/noxcturnal-win32-arm64-msvc@0.1.0':
+ resolution: {integrity: sha512-Wc+Zq26fux3byuCiKJVdW+NQYFdkTlsB7lhfr+w6fb6Xd4XVLAOOwzdocuvvKYLT0vA44StDgBbKDCQGSRGNDQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
- '@noxcturnal/noxcturnal-win32-x64-msvc@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
- resolution: {integrity: sha512-lZhOmUPSdekQ6lnzOAQeC7O1aKpr2vco2RKUz+aDAUmRXZgzpirdK1jri1cpgWemb278Za5FXXZq+O0v0aYVTQ==}
+ '@noxcturnal/noxcturnal-win32-x64-msvc@0.1.0':
+ resolution: {integrity: sha512-1Lat4+4WaiYLMm2dbnwEthj0Kx0i2ycwwcJ1JEjDXXXf6I113tSSSmCDmo4C/PAvXBGTQLWw3/8scoY2QA3G5w==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
@@ -13260,8 +13260,8 @@ packages:
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
engines: {node: '>=10'}
- noxcturnal@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc:
- resolution: {integrity: sha512-kR6JyVKbh4wVjll329UI+jA2OBoTeCX65I0wejTFX+KAP1poW6XR5+r+TcO6PbbyASTWNThc4UFqTM2dv1ee2A==}
+ noxcturnal@0.1.0:
+ resolution: {integrity: sha512-BkFZhSdTpH0L5z+nvWYj1IyhcCTYobg6HVQm6u7iW41zum43axs7I14tcXwy3a1ugDYRbSoXpzZ8+XqON7R1Uw==}
engines: {node: '>=18'}
npm-bundled@2.0.1:
@@ -17274,28 +17274,28 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
- '@noxcturnal/noxcturnal-darwin-arm64@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
+ '@noxcturnal/noxcturnal-darwin-arm64@0.1.0':
optional: true
- '@noxcturnal/noxcturnal-darwin-x64@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
+ '@noxcturnal/noxcturnal-darwin-x64@0.1.0':
optional: true
- '@noxcturnal/noxcturnal-linux-arm64-gnu@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
+ '@noxcturnal/noxcturnal-linux-arm64-gnu@0.1.0':
optional: true
- '@noxcturnal/noxcturnal-linux-arm64-musl@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
+ '@noxcturnal/noxcturnal-linux-arm64-musl@0.1.0':
optional: true
- '@noxcturnal/noxcturnal-linux-x64-gnu@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
+ '@noxcturnal/noxcturnal-linux-x64-gnu@0.1.0':
optional: true
- '@noxcturnal/noxcturnal-linux-x64-musl@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
+ '@noxcturnal/noxcturnal-linux-x64-musl@0.1.0':
optional: true
- '@noxcturnal/noxcturnal-win32-arm64-msvc@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
+ '@noxcturnal/noxcturnal-win32-arm64-msvc@0.1.0':
optional: true
- '@noxcturnal/noxcturnal-win32-x64-msvc@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc':
+ '@noxcturnal/noxcturnal-win32-x64-msvc@0.1.0':
optional: true
'@octokit/auth-token@3.0.4': {}
@@ -22772,19 +22772,19 @@ snapshots:
normalize-url@6.1.0: {}
- noxcturnal@0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc:
+ noxcturnal@0.1.0:
dependencies:
'@babel/code-frame': 7.29.7
'@oxc-project/types': 0.141.0
optionalDependencies:
- '@noxcturnal/noxcturnal-darwin-arm64': 0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc
- '@noxcturnal/noxcturnal-darwin-x64': 0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc
- '@noxcturnal/noxcturnal-linux-arm64-gnu': 0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc
- '@noxcturnal/noxcturnal-linux-arm64-musl': 0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc
- '@noxcturnal/noxcturnal-linux-x64-gnu': 0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc
- '@noxcturnal/noxcturnal-linux-x64-musl': 0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc
- '@noxcturnal/noxcturnal-win32-arm64-msvc': 0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc
- '@noxcturnal/noxcturnal-win32-x64-msvc': 0.1.1-canary-5e28645fa13a4dee3b1814eac789f79b8e7259bc
+ '@noxcturnal/noxcturnal-darwin-arm64': 0.1.0
+ '@noxcturnal/noxcturnal-darwin-x64': 0.1.0
+ '@noxcturnal/noxcturnal-linux-arm64-gnu': 0.1.0
+ '@noxcturnal/noxcturnal-linux-arm64-musl': 0.1.0
+ '@noxcturnal/noxcturnal-linux-x64-gnu': 0.1.0
+ '@noxcturnal/noxcturnal-linux-x64-musl': 0.1.0
+ '@noxcturnal/noxcturnal-win32-arm64-msvc': 0.1.0
+ '@noxcturnal/noxcturnal-win32-x64-msvc': 0.1.0
npm-bundled@2.0.1:
dependencies: