diff --git a/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js b/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js index 01ba27d442..fbb8fb2d2f 100644 --- a/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js +++ b/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js @@ -93,6 +93,78 @@ describe('transform', () => { }) }) +/* + * The plugin memoizes filesystem stats, barrel export maps, and verified + * chains in module-level caches that outlive individual transforms. A lexicon + * regen inside a long-lived worker (Metro dev server, jest --watch) must + * invalidate them - the plugin uses the root index mtime as the epoch. + */ +describe('cache invalidation across a lexicon regen', () => { + let tmp + let lexRoot + + function write(rel, content) { + const file = path.join(tmp, rel) + fs.mkdirSync(path.dirname(file), {recursive: true}) + fs.writeFileSync(file, content) + } + + beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lexicon-regen-')) + lexRoot = path.join(tmp, 'lexicons') + write('lexicons/index.ts', `export * as app from './app'\n`) + write('lexicons/app.ts', `export * as bsky from './app/bsky'\n`) + write('lexicons/app/bsky.ts', `export * as feed from './bsky/feed'\n`) + write('lexicons/app/bsky/feed.ts', `export * as like from './feed/like'\n`) + write('lexicons/app/bsky/feed/like.ts', `export const $type = 'test'\n`) + }) + + afterAll(() => { + fs.rmSync(tmp, {recursive: true, force: true}) + }) + + function transform() { + return transformSync( + `import {app} from './lexicons'\nvoid app.bsky.feed.like\n`, + { + filename: path.join(tmp, 'consumer.ts'), + configFile: false, + babelrc: false, + parserOpts: {plugins: ['typescript']}, + plugins: [[plugin, {roots: [lexRoot]}]], + }, + ).code + } + + test('a layout change is picked up without a process restart', () => { + expect(transform()).toContain( + `import * as _lex_app_bsky_feed_like from "./lexicons/app/bsky/feed/like"`, + ) + + /* + * Simulate `lex build --clear` deepening the leaf into a barrel. Codegen + * rewrites the whole tree, so the root index mtime always moves; force it + * forward explicitly since same-millisecond writes would hide the change. + */ + write( + 'lexicons/app/bsky/feed/like.ts', + `export * as main from './like/main'\n`, + ) + write('lexicons/app/bsky/feed/like/main.ts', `export const $type = 'test'\n`) + const bumped = new Date(Date.now() + 10_000) + fs.utimesSync(path.join(lexRoot, 'index.ts'), bumped, bumped) + + /* + * The chain now stops at a barrel, so the correct result is a bail that + * keeps the barrel import. Stale caches would instead replay the rewrite + * against the old layout. + */ + const out = transform() + expect(out).toContain(`from './lexicons'`) + expect(out).not.toContain('import *') + }) +}) + /** * Enumerate every leaf chain by following `export * as from '...'` * through the barrel graph, mirroring the plugin's own leaf/barrel rule: a diff --git a/plugins/babel-plugin-lexicon-leaf-imports.js b/plugins/babel-plugin-lexicon-leaf-imports.js index 2e1194a2a0..8569ebb03d 100644 --- a/plugins/babel-plugin-lexicon-leaf-imports.js +++ b/plugins/babel-plugin-lexicon-leaf-imports.js @@ -142,7 +142,7 @@ function resolveBarrelTarget(fromFile, spec) { return null } -/** `rootDir\0segments` -> boolean */ +/** `rootDir\0segments\0leafFile` -> boolean */ const chainCache = new Map() /** @@ -152,7 +152,11 @@ const chainCache = new Map() * divergence; the caller turns that into a hard build error. */ function verifyChain(rootDir, segments, leafFile) { - const key = rootDir + '\0' + segments.join('.') + /* + * leafFile is part of the key: the cached boolean encodes `walk === leafFile`, + * so a hit for the same chain but a different planned leaf must not be reused. + */ + const key = rootDir + '\0' + segments.join('.') + '\0' + leafFile let ok = chainCache.get(key) if (ok !== undefined) return ok let cur = leafFileFor(rootDir, 'index') @@ -172,6 +176,34 @@ function verifyChain(rootDir, segments, leafFile) { return ok } +/** Barrel root dir -> mtimeMs of its index file when the caches were filled. */ +const rootEpochs = new Map() + +/* + * The caches above are module-level and would otherwise outlive a lexicon + * regen inside a long-lived Metro or Jest watch worker, serving stale stats, + * export maps, and verified chains until the process restarts. Codegen + * (`lex build --clear`) rewrites the whole tree including the root index, so + * the index mtime works as an epoch: when it moves, drop all three caches. + * Costs one statSync per file that actually imports a barrel. + */ +function invalidateStaleCaches(rootDir) { + let mtime = -1 + for (const ext of EXTS) { + try { + mtime = fs.statSync(path.join(rootDir, 'index' + ext)).mtimeMs + break + } catch {} + } + const prev = rootEpochs.get(rootDir) + if (prev !== undefined && prev !== mtime) { + statCache.clear() + barrelExportCache.clear() + chainCache.clear() + } + rootEpochs.set(rootDir, mtime) +} + const SDK_SEGMENT = `${path.sep}@bsky${path.sep}sdk${path.sep}` const SDK_BARREL_RE = /(^|\/)lexicons\/index\.js$/ @@ -271,6 +303,9 @@ module.exports = function lexiconLeafImports(babel, options = {}) { if (dir) targets.push({imp: stmt, dir}) } if (targets.length === 0) return + for (const {dir} of targets) { + invalidateStaleCaches(dir) + } /* * Type-only references were stripped by the TypeScript transform