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
This commit is contained in:
Jinwoo Hong
2026-09-22 07:57:43 -04:00
committed by GitHub
parent 632ae1320b
commit b76bc79d73
3 changed files with 188 additions and 0 deletions
@@ -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([])
})
})
+12
View File
@@ -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
@@ -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)
})
})