mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
`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
89 lines
3.9 KiB
TypeScript
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)
|
|
})
|
|
})
|