Files
orca/mobile/scripts/metro-shell-cache-key.test.ts
T
Jinwoo Hong b76bc79d73 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
2026-09-22 07:57:43 -04:00

89 lines
3.9 KiB
TypeScript

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)
})
})