From b76bc79d73d12a3489bb30e5d438952ec5d0582c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:57:43 -0400 Subject: [PATCH] build(mobile): key Metro's transform cache on the shell build kind (#22244) `babel-preset-expo` inlines `EXPO_PUBLIC_MOBILE_SHELL` into `mobileShellBuildKind` at transform time (babel-preset-expo/build/inline-env-vars.js:51), but nothing Metro hashes into the transform cache key carries that value: the key is `metro/src/DeltaBundler/getTransformCacheKey.js:21`, whose inputs are the Metro version, `cacheVersion`, the transformer path and `@expo/metro-config/build/transform-worker/metro-transform-worker.js:600`, none of which reads the environment. A release assembled after an opposite-kind build reuses the warm entries and bakes the wrong shell, and the absence of the variable's name in the bundle cannot tell the two apart. The newest published `@expo/metro-config` (58.0.4) keys it no differently. Folds the kind into `cacheVersion`, by the same `=== 'ota'` rule the app applies, keeping Metro's own version as the prefix. Proven with four `expo export --platform android` runs against an isolated Metro cache. Before: `ota` then `native` produced byte-identical bundles, both `return 'ota'`. After: the second run returns `'native'` under a different bundle hash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...ile-release-shell-switch-workflow.test.mjs | 88 +++++++++++++++++++ mobile/metro.config.js | 12 +++ mobile/scripts/metro-shell-cache-key.test.ts | 88 +++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 mobile/scripts/metro-shell-cache-key.test.ts diff --git a/config/scripts/mobile-release-shell-switch-workflow.test.mjs b/config/scripts/mobile-release-shell-switch-workflow.test.mjs index cfb651b9cc1..9e22d799cfb 100644 --- a/config/scripts/mobile-release-shell-switch-workflow.test.mjs +++ b/config/scripts/mobile-release-shell-switch-workflow.test.mjs @@ -103,3 +103,91 @@ it('leaves the switch out of every other workflow, so only a release can set it' expect(setters).toEqual(['mobile-android-release.yml', 'mobile-ios-release.yml']) }) + +/** + * The other half of the switch: what a restored bundler cache would do to it. + * + * `babel-preset-expo` inlines the variable at transform time, but nothing Metro hashes into the + * transform cache key carries its value, so a Metro cache restored from a run of the opposite kind + * returns the opposite shell byte for byte. `mobile/metro.config.js` folds the kind into + * `cacheVersion` and so survives one; a cache keyed by these workflows would have to name the shell + * too, and today none of them restores one at all. + */ +const MOBILE_WORKFLOWS = ['mobile.yml', 'mobile-android-release.yml', 'mobile-ios-release.yml'] +/** Paths under which a Metro or Expo build cache lives, in the spellings a workflow would use. */ +const BUNDLER_CACHE_PATHS = ['metro-cache', '.expo', 'node_modules/.cache'] +/** The one restored path these workflows compute in a script, and so this test cannot read. */ +const REVIEWED_COMPUTED_PATH = '${{ steps.electron-package-cache.outputs.cache-root }}' + +/** Every step a workflow runs, descending into the repository's own composite actions. */ +function stepsIncludingComposites(file) { + const collect = (owner, steps, into) => { + for (const step of steps ?? []) { + into.push({ owner, step }) + if (typeof step.uses === 'string' && step.uses.startsWith('./')) { + const action = parse(readFileSync(resolve(projectDir, step.uses, 'action.yml'), 'utf8')) + collect(step.uses, action.runs?.steps, into) + } + } + return into + } + return Object.entries(workflowOf(file).jobs).flatMap(([job, body]) => + collect(job, body.steps, []) + ) +} + +/** What those steps restore: `actions/cache`, and the setup actions that carry one of their own. */ +function cacheRestores(file) { + return stepsIncludingComposites(file).flatMap(({ owner, step }) => { + const uses = typeof step.uses === 'string' ? step.uses : '' + const named = { name: `${owner}: ${step.name ?? uses}` } + if (/^actions\/cache(\/restore)?@/.test(uses)) { + return [{ ...named, paths: String(step.with?.path ?? ''), key: String(step.with?.key ?? '') }] + } + if (uses.startsWith('actions/setup-node@') && step.with?.cache) { + return [{ ...named, paths: `${step.with.cache} store`, key: '' }] + } + if (uses.startsWith('ruby/setup-ruby@') && step.with?.['bundler-cache']) { + return [{ ...named, paths: 'bundler vendor', key: '' }] + } + return [] + }) +} + +const MOBILE_CACHE_RESTORES = MOBILE_WORKFLOWS.flatMap((file) => cacheRestores(file)) + +describe('what the mobile jobs restore from cache', () => { + it('sees the caches these jobs already have, so the rule below cannot pass vacuously', () => { + const names = MOBILE_CACHE_RESTORES.map(({ name }) => name) + + // One from a composite action and one declared in a workflow: a walk that stopped at either + // boundary would report an empty list and call it clean. + expect(names).toEqual( + expect.arrayContaining([ + './.github/actions/install-node-dependencies: Cache Electron package archive', + './.github/actions/install-node-dependencies: Restore compiled native modules', + './.github/actions/install-node-dependencies: Setup Node.js', + 'ios-build: Setup Ruby and fastlane' + ]) + ) + }) + + it('reads every restored path, rather than passing one it cannot evaluate', () => { + const computed = MOBILE_CACHE_RESTORES.filter(({ paths }) => paths.includes('${{')) + + expect(computed.map(({ paths }) => paths)).toEqual(computed.map(() => REVIEWED_COMPUTED_PATH)) + }) + + it('restores no Metro or Expo build cache, which would decide the shell before the env does', () => { + const bundlerCaches = MOBILE_CACHE_RESTORES.filter(({ paths }) => + BUNDLER_CACHE_PATHS.some((needle) => paths.includes(needle)) + ) + + // A restored one is not fatal — it just has to name the shell, the way `cacheVersion` does. + expect( + bundlerCaches.filter(({ key }) => !key.includes(SWITCH) && !key.includes('inputs.shell')), + bundlerCaches.map(({ name, paths }) => `${name}: ${paths}`).join('\n') + ).toEqual([]) + expect(bundlerCaches).toEqual([]) + }) +}) diff --git a/mobile/metro.config.js b/mobile/metro.config.js index d3b7695e3ba..ce6afb51b24 100644 --- a/mobile/metro.config.js +++ b/mobile/metro.config.js @@ -10,4 +10,16 @@ const config = getDefaultConfig(projectRoot) // Metro only watches mobile/ by default, so make repo-root shared modules visible. config.watchFolders = Array.from(new Set([...(config.watchFolders ?? []), sharedRoot])) +/** + * The shell kind the bundle is being built for, by the same rule `mobileShellBuildKind` applies in + * `src/storage/preferences.ts`. This read runs in Node at config time, so it is not the second + * inlined read that module's census forbids. + */ +const shellBuildKind = process.env.EXPO_PUBLIC_MOBILE_SHELL === 'ota' ? 'ota' : 'native' + +// Why: babel-preset-expo inlines EXPO_PUBLIC_MOBILE_SHELL at transform time, but nothing Metro +// hashes into the transform cache key carries that value, so a warm cache from the opposite kind +// silently bakes the wrong shell into a release. +config.cacheVersion = `${config.cacheVersion}-shell-${shellBuildKind}` + module.exports = config diff --git a/mobile/scripts/metro-shell-cache-key.test.ts b/mobile/scripts/metro-shell-cache-key.test.ts new file mode 100644 index 00000000000..aec34d40cec --- /dev/null +++ b/mobile/scripts/metro-shell-cache-key.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +/** + * Metro's transform cache is what decides which shell a release bakes in. + * + * `babel-preset-expo` inlines `EXPO_PUBLIC_MOBILE_SHELL` into `mobileShellBuildKind` at transform + * time, but nothing Metro hashes into the transform cache key carries that value, so a warm cache + * seeded by the opposite kind returns the opposite shell byte for byte — and the absence of the + * variable's name in the bundle cannot tell the two apart. `cacheVersion` is the one input to that + * key a project owns. + */ +const MOBILE_ROOT = join(import.meta.dirname, '..') +const CONFIG_PATH = join(MOBILE_ROOT, 'metro.config.js') +const SWITCH = 'EXPO_PUBLIC_MOBILE_SHELL' +/** The one expression both the config and the app answer the build kind with. */ +const BUILD_KIND_RULE = `process.env.${SWITCH} === 'ota' ? 'ota' : 'native'` +const APP_DEFINITION = join(MOBILE_ROOT, 'src', 'storage', 'preferences.ts') +const requireConfig = createRequire(import.meta.url) +const originalSwitch = process.env[SWITCH] + +function cacheVersionOf(loaded: unknown): string { + if (typeof loaded !== 'object' || loaded === null || !('cacheVersion' in loaded)) { + throw new Error('metro.config.js exported no cacheVersion') + } + const { cacheVersion } = loaded + if (typeof cacheVersion !== 'string') { + throw new Error(`metro.config.js exported a non-string cacheVersion: ${typeof cacheVersion}`) + } + return cacheVersion +} + +/** Re-evaluates the config, which is the only way the env read at its top level runs again. */ +function cacheVersionFor(shell: string | undefined): string { + if (shell === undefined) { + Reflect.deleteProperty(process.env, SWITCH) + } else { + process.env[SWITCH] = shell + } + Reflect.deleteProperty(requireConfig.cache, requireConfig.resolve(CONFIG_PATH)) + return cacheVersionOf(requireConfig(CONFIG_PATH)) +} + +afterEach(() => { + if (originalSwitch === undefined) { + Reflect.deleteProperty(process.env, SWITCH) + } else { + process.env[SWITCH] = originalSwitch + } +}) + +describe("metro's transform cache key", () => { + it('separates the two shell kinds, so neither can be served a warm cache of the other', () => { + expect(cacheVersionFor('ota')).not.toEqual(cacheVersionFor('native')) + }) + + it('reads the kind by the same rule the app applies, so the key names the shell it bakes', () => { + const base = cacheVersionFor(undefined).replace(/-shell-native$/, '') + + expect(cacheVersionFor('ota')).toBe(`${base}-shell-ota`) + expect(cacheVersionFor('native')).toBe(`${base}-shell-native`) + // Every other value is a native build to the app, and so has to be one to the cache as well. + expect(cacheVersionFor(undefined)).toBe(`${base}-shell-native`) + expect(cacheVersionFor('')).toBe(`${base}-shell-native`) + expect(cacheVersionFor('OTA')).toBe(`${base}-shell-native`) + expect(cacheVersionFor('page')).toBe(`${base}-shell-native`) + }) + + it('spells that rule the same way the app does, so the two cannot drift apart silently', () => { + // A copy is unavoidable: the config runs in Node at bundle time and the app module is a React + // Native one, so neither can import the other. Pinning the spelling is what keeps them equal. + for (const path of [CONFIG_PATH, APP_DEFINITION]) { + const text = readFileSync(path, 'utf8') + const line = text.split('\n').find((candidate) => candidate.includes(BUILD_KIND_RULE)) + + expect(line, `${path} does not spell: ${BUILD_KIND_RULE}`).toBeDefined() + } + }) + + it("keeps metro's own cache version, which invalidates on a bundler upgrade", () => { + // Replacing it rather than extending it would trade this bug for that one. + const defaultConfig: unknown = requireConfig('expo/metro-config').getDefaultConfig(MOBILE_ROOT) + + expect(cacheVersionFor('ota').startsWith(`${cacheVersionOf(defaultConfig)}-`)).toBe(true) + }) +})