@tamagui/metro-plugin 2.7.7 → 3.0.0-beta.643.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +12 -0
  2. package/dist/cjs/babel.cjs +77 -0
  3. package/dist/cjs/compilerCache.cjs +237 -0
  4. package/dist/cjs/diagnostics.cjs +41 -0
  5. package/dist/cjs/frontend.cjs +870 -0
  6. package/dist/cjs/index.cjs +102 -0
  7. package/dist/cjs/lowering.cjs +109 -0
  8. package/dist/cjs/metroResolver.cjs +197 -0
  9. package/dist/cjs/transformOptions.cjs +35 -0
  10. package/dist/cjs/transformer.cjs +142 -0
  11. package/dist/cjs/zeroRuntime.cjs +140 -0
  12. package/dist/cjs/zeroSerializer.cjs +150 -0
  13. package/dist/esm/babel.mjs +52 -0
  14. package/dist/esm/babel.mjs.map +1 -0
  15. package/dist/esm/compilerCache.mjs +212 -0
  16. package/dist/esm/compilerCache.mjs.map +1 -0
  17. package/dist/esm/diagnostics.mjs +18 -0
  18. package/dist/esm/diagnostics.mjs.map +1 -0
  19. package/dist/esm/frontend.mjs +839 -0
  20. package/dist/esm/frontend.mjs.map +1 -0
  21. package/dist/esm/index.mjs +64 -22
  22. package/dist/esm/index.mjs.map +1 -1
  23. package/dist/esm/lowering.mjs +89 -0
  24. package/dist/esm/lowering.mjs.map +1 -0
  25. package/dist/esm/metroResolver.mjs +173 -0
  26. package/dist/esm/metroResolver.mjs.map +1 -0
  27. package/dist/esm/transformOptions.mjs +14 -0
  28. package/dist/esm/transformOptions.mjs.map +1 -0
  29. package/dist/esm/transformer.mjs +119 -0
  30. package/dist/esm/transformer.mjs.map +1 -0
  31. package/dist/esm/zeroRuntime.mjs +105 -0
  32. package/dist/esm/zeroRuntime.mjs.map +1 -0
  33. package/dist/esm/zeroSerializer.mjs +123 -0
  34. package/dist/esm/zeroSerializer.mjs.map +1 -0
  35. package/package.json +33 -5
  36. package/src/babel.ts +87 -0
  37. package/src/compilerCache.ts +346 -0
  38. package/src/diagnostics.ts +47 -0
  39. package/src/frontend.ts +1178 -0
  40. package/src/index.ts +117 -14
  41. package/src/lowering.ts +136 -0
  42. package/src/metroResolver.ts +209 -0
  43. package/src/transformOptions.ts +36 -0
  44. package/src/transformer.ts +210 -0
  45. package/src/zeroRuntime.ts +212 -0
  46. package/src/zeroSerializer.ts +175 -0
  47. package/types/babel.d.ts +28 -0
  48. package/types/babel.d.ts.map +11 -0
  49. package/types/compilerCache.d.ts +63 -0
  50. package/types/compilerCache.d.ts.map +11 -0
  51. package/types/diagnostics.d.ts +16 -0
  52. package/types/diagnostics.d.ts.map +11 -0
  53. package/types/frontend.d.ts +73 -0
  54. package/types/frontend.d.ts.map +11 -0
  55. package/types/index.d.ts +49 -32
  56. package/types/index.d.ts.map +11 -1
  57. package/types/lowering.d.ts +20 -0
  58. package/types/lowering.d.ts.map +11 -0
  59. package/types/metroResolver.d.ts +21 -0
  60. package/types/metroResolver.d.ts.map +11 -0
  61. package/types/transformOptions.d.ts +13 -0
  62. package/types/transformOptions.d.ts.map +11 -0
  63. package/types/transformer.d.ts +26 -0
  64. package/types/transformer.d.ts.map +11 -0
  65. package/types/zeroRuntime.d.ts +75 -0
  66. package/types/zeroRuntime.d.ts.map +11 -0
  67. package/types/zeroSerializer.d.ts +6 -0
  68. package/types/zeroSerializer.d.ts.map +11 -0
  69. package/dist/cjs/index.js +0 -45
  70. package/dist/cjs/index.js.map +0 -6
  71. package/dist/esm/index.js +0 -25
  72. package/dist/esm/index.js.map +0 -1
@@ -0,0 +1,1178 @@
1
+ import { existsSync, watch, type FSWatcher } from 'node:fs'
2
+ import { readFile, readdir, realpath } from 'node:fs/promises'
3
+ import { createRequire } from 'node:module'
4
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
5
+
6
+ import ignore, { type Ignore } from 'ignore'
7
+
8
+ import {
9
+ JsonFileCache,
10
+ ModulePlanCache,
11
+ PLAN_CACHE_SCHEMA_VERSION,
12
+ ProjectGraph,
13
+ contentHash,
14
+ defaultPlanCacheRoot,
15
+ lowerModule,
16
+ materializeModule,
17
+ moduleClosureDigest,
18
+ moduleClosureNode,
19
+ planCacheKey,
20
+ resolvedModuleId,
21
+ stableStringify,
22
+ yukuFactory,
23
+ type CompilerLoweringHost,
24
+ type CompilerTarget,
25
+ type HostModuleInput,
26
+ type HostResolvedImport,
27
+ type LoweredModulePlan,
28
+ type ModuleClosureNode,
29
+ type ResolvedModuleId,
30
+ } from '@tamagui/compiler-core'
31
+ import Static, { createTamaguiCompilerHost } from '@tamagui/static'
32
+ import type {
33
+ IslandThemeBridge,
34
+ TamaguiOptions,
35
+ TamaguiProjectInfo,
36
+ } from '@tamagui/static'
37
+
38
+ import {
39
+ compileWithUserBabel,
40
+ userBabelCacheKey,
41
+ type MetroBabelTransformArgs,
42
+ } from './babel'
43
+ import { zeroModuleKey, type MetroZeroController } from './zeroRuntime'
44
+ import {
45
+ METRO_COMPILER_CACHE_VERSION,
46
+ MetroCompilerCache,
47
+ defaultMetroCompilerCacheRoot,
48
+ type MetroCompilerCacheEntry,
49
+ } from './compilerCache'
50
+ import { metroDiagnostic, type MetroCompilerDiagnostic } from './diagnostics'
51
+ import {
52
+ createMetroCompilerResolver,
53
+ isCompilerSourceFile,
54
+ moduleSpecifiersFromAst,
55
+ type MetroResolverConfig,
56
+ } from './metroResolver'
57
+
58
+ interface CompiledRecord {
59
+ input: HostModuleInput
60
+ sourceHash: string
61
+ /** Specifiers that reached the compiled output as require() calls instead of imports. */
62
+ requireSpecifiers: string[]
63
+ }
64
+
65
+ /**
66
+ * Metro runs the user's whole Babel transformer over every project source just
67
+ * to read its import specifiers, which is the single most expensive step of the
68
+ * prepass. The result is a pure function of the module's own bytes plus the
69
+ * resolver and Babel identity, so it caches per file with no closure involved.
70
+ */
71
+ export const METRO_RECORD_CACHE_VERSION = 1
72
+
73
+ interface CachedRecord {
74
+ schemaVersion: typeof METRO_RECORD_CACHE_VERSION
75
+ sourceHash: string
76
+ imports: HostResolvedImport[]
77
+ requireSpecifiers: string[]
78
+ /** Resolve failures replayed on a hit, so a cached record reports what a fresh one did. */
79
+ diagnostics: MetroCompilerDiagnostic[]
80
+ }
81
+
82
+ export interface MetroCompilerFrontendConfig extends MetroResolverConfig {
83
+ cacheRoot?: string
84
+ /** Present only for an enforced zero-runtime web build. */
85
+ zero?: MetroZeroController | null
86
+ originalBabelTransformerPath: string
87
+ transformer?: Record<string, any>
88
+ tamaguiOptions?: Partial<TamaguiOptions>
89
+ loadCompilerProject?: (
90
+ target: CompilerTarget,
91
+ platform: string | null
92
+ ) => Promise<MetroCompilerProject>
93
+ watch?: boolean
94
+ reportDiagnostic?: (diagnostic: MetroCompilerDiagnostic) => void
95
+ }
96
+
97
+ export interface MetroCompilerProject extends Static.CompilerProject {}
98
+
99
+ export interface MetroCompilerScanOptions {
100
+ dev: boolean
101
+ entryFiles: readonly string[]
102
+ hot: boolean
103
+ platform: string | null
104
+ transform?: Record<string, any>
105
+ }
106
+
107
+ export interface MetroCompilerGeneration {
108
+ generation: string
109
+ moduleIds: string[]
110
+ diagnostics: MetroCompilerDiagnostic[]
111
+ }
112
+
113
+ export interface MetroCompilerUpdate {
114
+ changed: boolean
115
+ affectedIds: string[]
116
+ generation: string | null
117
+ }
118
+
119
+ function compareCodeUnits(left: string, right: string): number {
120
+ return left < right ? -1 : left > right ? 1 : 0
121
+ }
122
+
123
+ const requireFromFrontend = createRequire(
124
+ typeof __filename === 'string' ? __filename : import.meta.url
125
+ )
126
+
127
+ // upgrading the compiler must invalidate published plans even when the Tamagui
128
+ // config output is unchanged
129
+ const compilerImplementationVersions = (
130
+ ['@tamagui/metro-plugin', '@tamagui/static', '@tamagui/compiler-core'] as const
131
+ ).map((packageName) => {
132
+ const { version } = requireFromFrontend(`${packageName}/package.json`) as {
133
+ version: string
134
+ }
135
+ return `${packageName}@${version}`
136
+ })
137
+
138
+ function scanOptionsHash(
139
+ options: MetroCompilerScanOptions,
140
+ projectGeneration: string,
141
+ projectSourcesHash: string
142
+ ): string {
143
+ return contentHash(JSON.stringify({ options, projectGeneration, projectSourcesHash }))
144
+ }
145
+
146
+ // Metro entries can live inside node_modules (expo-router's entry reaches app
147
+ // source only through require.context), so reachability from the entry alone
148
+ // discovers nothing there. Project source is walked directly and seeded into
149
+ // the scan alongside the entry; imports then extend the graph outside the
150
+ // project root (workspace packages) exactly as before.
151
+ //
152
+ // The walked list is both the seed set and the plan cache's options hash, so it
153
+ // has to be authored source only. Build output is whatever the project already
154
+ // declares as ignored, read with git's own rules: a directory-name list cannot
155
+ // know that `dist-metro`, `out` or `public/assets` are output, and a sibling
156
+ // bundler's content-hashed filenames then re-key the plan cache on every
157
+ // unrelated rebuild, forcing Metro to rescan a project that never changed.
158
+ // `node_modules` is skipped structurally instead, because that is the same
159
+ // externality boundary the resolver draws and it must hold with or without a
160
+ // declaration.
161
+ interface IgnoreScope {
162
+ dir: string
163
+ matcher: Ignore
164
+ }
165
+
166
+ const speculativeWalkExcludedDirs = new Set([
167
+ '__tests__',
168
+ 'e2e',
169
+ 'flows',
170
+ 'plugins',
171
+ 'screenshots',
172
+ 'scripts',
173
+ 'test',
174
+ 'test-results',
175
+ 'tests',
176
+ ])
177
+
178
+ async function walkProjectSources(root: string): Promise<string[]> {
179
+ // git reads every .gitignore from the repository root down to the file, so an
180
+ // app nested in a monorepo inherits the declarations made above it
181
+ const inherited: string[] = []
182
+ let ancestor = root
183
+ while (!existsSync(join(ancestor, '.git'))) {
184
+ const parent = dirname(ancestor)
185
+ if (parent === ancestor) break
186
+ inherited.unshift(parent)
187
+ ancestor = parent
188
+ }
189
+ const rootScopes: IgnoreScope[] = []
190
+ for (const dir of inherited) {
191
+ const source = await readFile(join(dir, '.gitignore'), 'utf8').catch(() => null)
192
+ if (source) rootScopes.push({ dir, matcher: ignore().add(source) })
193
+ }
194
+
195
+ const found: string[] = []
196
+ const stack: { dir: string; scopes: IgnoreScope[] }[] = [
197
+ { dir: root, scopes: rootScopes },
198
+ ]
199
+ while (stack.length) {
200
+ const { dir, scopes } = stack.pop()!
201
+ let entries
202
+ try {
203
+ entries = await readdir(dir, { withFileTypes: true })
204
+ } catch {
205
+ continue
206
+ }
207
+ let active = scopes
208
+ if (entries.some((entry) => entry.isFile() && entry.name === '.gitignore')) {
209
+ const source = await readFile(join(dir, '.gitignore'), 'utf8').catch(() => null)
210
+ if (source) active = [...scopes, { dir, matcher: ignore().add(source) }]
211
+ }
212
+ for (const entry of entries) {
213
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
214
+ const isDirectory = entry.isDirectory()
215
+ if (isDirectory && speculativeWalkExcludedDirs.has(entry.name)) continue
216
+ if (!isDirectory && !(entry.isFile() && isCompilerSourceFile(entry.name))) continue
217
+ if (
218
+ !isDirectory &&
219
+ (/(?:^|[-.])(?:probe|run|spec|tests?)(?:[-.]|$)/i.test(entry.name) ||
220
+ /\.(?:build|config|workspace)\.[cm]?[jt]sx?$/.test(entry.name))
221
+ ) {
222
+ continue
223
+ }
224
+ const path = join(dir, entry.name)
225
+ let ignored = false
226
+ for (const scope of active) {
227
+ const relativePath = relative(scope.dir, path)
228
+ if (!relativePath || relativePath.startsWith('..')) continue
229
+ const candidate = relativePath.split(sep).join('/') + (isDirectory ? '/' : '')
230
+ if (scope.matcher.ignores(candidate)) {
231
+ ignored = true
232
+ break
233
+ }
234
+ }
235
+ if (ignored) continue
236
+ if (isDirectory) stack.push({ dir: path, scopes: active })
237
+ else found.push(path)
238
+ }
239
+ }
240
+ return found.sort(compareCodeUnits)
241
+ }
242
+
243
+ function compilerTarget(platform: string | null): CompilerTarget {
244
+ return platform === 'web' ? 'web' : 'native'
245
+ }
246
+
247
+ function retainsLiveGraph(options: MetroCompilerScanOptions): boolean {
248
+ return options.dev && options.hot
249
+ }
250
+
251
+ export class MetroCompilerFrontend {
252
+ readonly #cacheBaseRoot: string
253
+ readonly #entries = new Map<ResolvedModuleId, MetroCompilerCacheEntry>()
254
+ readonly #records = new Map<ResolvedModuleId, CompiledRecord>()
255
+ readonly #watchers = new Map<ResolvedModuleId, FSWatcher>()
256
+ readonly #resolver
257
+ #graph: ProjectGraph | null = null
258
+ #host: CompilerLoweringHost | null = null
259
+ #projectGeneration: string | null = null
260
+ #publishedGeneration: string | null = null
261
+ #scanOptions: MetroCompilerScanOptions | null = null
262
+ #scanOptionsHash: string | null = null
263
+ #operationQueue: Promise<void> = Promise.resolve()
264
+ #tamaguiConfig: TamaguiProjectInfo['tamaguiConfig'] | null = null
265
+ #zeroEntryGraph: Set<ResolvedModuleId> | null = null
266
+ readonly #planKeys = new Map<ResolvedModuleId, { key: string; digest: string }>()
267
+ #recordCache: JsonFileCache | null = null
268
+ #recordCacheIdentity: string | null = null
269
+ #planCache: ModulePlanCache | null = null
270
+ #planCacheStamp: string | null = null
271
+
272
+ constructor(readonly config: MetroCompilerFrontendConfig) {
273
+ this.#cacheBaseRoot =
274
+ config.cacheRoot ?? defaultMetroCompilerCacheRoot(config.projectRoot)
275
+ this.#resolver = createMetroCompilerResolver(config)
276
+ }
277
+
278
+ get metroResolverVersion(): string {
279
+ return this.#resolver.version
280
+ }
281
+
282
+ /**
283
+ * Per-file cache accounting for the last scan. The point of these caches is
284
+ * that one edited module leaves every other module's entry valid, and this is
285
+ * how that is observed rather than assumed.
286
+ */
287
+ get compileCacheStats(): {
288
+ plans: { hits: number; misses: number; writes: number }
289
+ records: { hits: number; misses: number; writes: number }
290
+ } {
291
+ const empty = { hits: 0, misses: 0, writes: 0 }
292
+ return {
293
+ plans: this.#planCache?.stats ?? empty,
294
+ records: this.#recordCache?.stats ?? empty,
295
+ }
296
+ }
297
+
298
+ cacheRootFor(platform: string | null): string {
299
+ return join(this.#cacheBaseRoot, platform ?? 'default')
300
+ }
301
+
302
+ scan(options: MetroCompilerScanOptions): Promise<MetroCompilerGeneration> {
303
+ return this.#enqueue(() => this.#scan(options))
304
+ }
305
+
306
+ async #scan(
307
+ options: MetroCompilerScanOptions,
308
+ preparedProject?: MetroCompilerProject,
309
+ preparedProjectSources?: string[]
310
+ ): Promise<MetroCompilerGeneration> {
311
+ this.#scanOptions = options
312
+ this.#publishedGeneration = null
313
+ const diagnostics: MetroCompilerDiagnostic[] = []
314
+ const entryRoots = (
315
+ await Promise.all(
316
+ options.entryFiles.map((path) => realpath(resolve(this.config.projectRoot, path)))
317
+ )
318
+ ).sort(compareCodeUnits)
319
+ const compilerProject =
320
+ preparedProject ??
321
+ (await this.#loadCompilerProject(options, entryRoots[0], diagnostics))
322
+ this.#projectGeneration = compilerProject.generation
323
+ const projectSources =
324
+ preparedProjectSources ?? (await walkProjectSources(this.config.projectRoot))
325
+ const projectSourcesHash = contentHash(JSON.stringify(projectSources))
326
+ this.#scanOptionsHash = scanOptionsHash(
327
+ options,
328
+ compilerProject.generation,
329
+ projectSourcesHash
330
+ )
331
+ this.#installCaches(options, compilerProject, projectSourcesHash)
332
+ const speculativeRoots = new Set<string>()
333
+ for (const file of projectSources) {
334
+ try {
335
+ const id = await realpath(file)
336
+ if (!entryRoots.includes(id)) speculativeRoots.add(id)
337
+ } catch {}
338
+ }
339
+ const roots = [...new Set([...entryRoots, ...speculativeRoots])].sort(
340
+ compareCodeUnits
341
+ )
342
+ const queue = [...roots]
343
+ const queued = new Set(queue)
344
+ for (const watcher of this.#watchers.values()) watcher.close()
345
+ this.#watchers.clear()
346
+ this.#records.clear()
347
+
348
+ while (queue.length) {
349
+ const path = queue.shift()!
350
+ try {
351
+ const record = await this.#compileRecord(path, options, diagnostics)
352
+ this.#records.set(record.input.id, record)
353
+ for (const dependency of record.input.imports) {
354
+ if (
355
+ dependency.external ||
356
+ !isCompilerSourceFile(dependency.resolvedId) ||
357
+ queued.has(dependency.resolvedId)
358
+ ) {
359
+ continue
360
+ }
361
+ queued.add(dependency.resolvedId)
362
+ queue.push(dependency.resolvedId)
363
+ }
364
+ } catch (error) {
365
+ // walk-seeded files are speculative: nothing proved the bundle needs
366
+ // them, so a compile failure is not a build diagnostic. If the bundle
367
+ // does include one, the transformer's plan-miss warning still fires.
368
+ if (speculativeRoots.has(path)) continue
369
+ const diagnostic = metroDiagnostic(
370
+ 'metro/transform-failed',
371
+ `Failed to compile ${path}: ${error instanceof Error ? error.message : String(error)}`,
372
+ { moduleId: path }
373
+ )
374
+ diagnostics.push(diagnostic)
375
+ this.#report(diagnostic)
376
+ }
377
+ }
378
+
379
+ if (
380
+ !compilerProject.projectInfo.tamaguiConfig ||
381
+ !compilerProject.projectInfo.components
382
+ ) {
383
+ throw new Error('Metro compiler project has no Tamagui config or components')
384
+ }
385
+ this.#tamaguiConfig = compilerProject.projectInfo.tamaguiConfig
386
+ this.#entries.clear()
387
+ const unplanned = await this.#restorePlans(options)
388
+ const zero = this.config.zero
389
+ // a scan that restores everything builds no graph, so the previous scan's
390
+ // graph must not survive as this scan's answer
391
+ this.#graph = null
392
+ this.#host = null
393
+ // Nothing left to compile and no live session to serve means the analyzer
394
+ // graph is never read, so it is never built. Parsing and linking every
395
+ // project source is the other half of the prepass cost.
396
+ if (unplanned.length || retainsLiveGraph(options)) {
397
+ this.#graph = new ProjectGraph(yukuFactory, {
398
+ modules: [...this.#records.values()].map(({ input }) => input),
399
+ })
400
+ this.#host = createTamaguiCompilerHost({
401
+ target: compilerTarget(options.platform),
402
+ tamaguiConfig: compilerProject.projectInfo.tamaguiConfig,
403
+ components: compilerProject.projectInfo.components,
404
+ componentModules: compilerProject.componentModules.map(({ moduleName, id }) => ({
405
+ moduleName,
406
+ resolvedId: id,
407
+ })),
408
+ disablePartialExtraction: compilerProject.disablePartialExtraction,
409
+ experimentalNativeFastPath: compilerProject.experimentalNativeFastPath,
410
+ zeroRuntime: compilerProject.zeroRuntime,
411
+ })
412
+ if (zero) {
413
+ if (zero.isEnforcing) {
414
+ Static.assertZeroConfigDrivers(compilerProject.projectInfo.tamaguiConfig)
415
+ }
416
+ zero.plansRestoredFromCache = false
417
+ zero.configCSS = compilerProject.projectInfo.tamaguiConfig.getCSS?.() ?? ''
418
+ zero.artifact.clearGraphs()
419
+ zero.bridges.clear()
420
+ zero.violations.length = 0
421
+ zero.transformed.clear()
422
+ zero.erasedExports.clear()
423
+ // The zero contract applies to an ENTRY GRAPH. Metro's frontend plans
424
+ // every project source by directory walk, so a config module, a control
425
+ // fixture, or another entry's page would otherwise be judged against a
426
+ // contract they are not part of.
427
+ this.#zeroEntryGraph = this.#reachableFrom(entryRoots.map(resolvedModuleId))
428
+ }
429
+ for (const id of unplanned) this.#refreshEntry(id)
430
+ await this.#storePlans(unplanned)
431
+ }
432
+ if (zero) {
433
+ // Written in both modes and before the failure, so `report` and `enforce`
434
+ // emit the identical list and only their exit differs.
435
+ Static.writeZeroViolationReport(zero.resolved.outDir, 'metro-zero', {
436
+ integration: 'metro-web',
437
+ mode: zero.isEnforcing ? 'enforce' : 'report',
438
+ violations: zero.violations,
439
+ })
440
+ if (zero.isEnforcing && zero.violations.length) {
441
+ throw new Error(Static.formatZeroViolations(zero.violations))
442
+ }
443
+ }
444
+ const totalFound = [...this.#entries.values()].reduce(
445
+ (sum, entry) => sum + entry.plan.stats.found,
446
+ 0
447
+ )
448
+ if (this.#entries.size > 0 && totalFound === 0) {
449
+ const componentNames = compilerProject.componentModules.map(
450
+ ({ moduleName }) => moduleName
451
+ )
452
+ const cjsComponentImporters = [...this.#records.values()].filter((record) =>
453
+ record.requireSpecifiers.some((specifier) =>
454
+ componentNames.some(
455
+ (name) => specifier === name || specifier.startsWith(`${name}/`)
456
+ )
457
+ )
458
+ ).length
459
+ if (cjsComponentImporters > 0) {
460
+ const diagnostic = metroDiagnostic(
461
+ 'metro/no-linked-components',
462
+ `The Tamagui compiler linked 0 components across ${this.#entries.size} modules even though ` +
463
+ `${cjsComponentImporters} module(s) reference ${componentNames.join(', ')} through require() calls. ` +
464
+ `Metro compiled modules to CommonJS before the compiler could analyze them, so component ` +
465
+ `imports cannot be linked and nothing will be optimized. Enable experimentalImportSupport ` +
466
+ `in your transformer's getTransformOptions (Expo enables it by default) to restore ` +
467
+ `Tamagui compilation.`
468
+ )
469
+ diagnostics.push(diagnostic)
470
+ this.#report(diagnostic)
471
+ }
472
+ }
473
+ const generation = await this.#publish(options.platform)
474
+ const moduleIds = [...this.#records.keys()].sort(compareCodeUnits)
475
+ if (this.config.watch !== false && retainsLiveGraph(options)) {
476
+ this.#installWatchers()
477
+ } else if (!retainsLiveGraph(options)) {
478
+ this.#releaseGraph()
479
+ }
480
+ return {
481
+ generation,
482
+ moduleIds,
483
+ diagnostics,
484
+ }
485
+ }
486
+
487
+ ensureValidCache(options: MetroCompilerScanOptions): Promise<MetroCompilerGeneration> {
488
+ return this.#enqueue(() => this.#ensureValidCache(options))
489
+ }
490
+
491
+ async #ensureValidCache(
492
+ options: MetroCompilerScanOptions
493
+ ): Promise<MetroCompilerGeneration> {
494
+ const diagnostics: MetroCompilerDiagnostic[] = []
495
+ const firstEntry = options.entryFiles[0]
496
+ const importer = firstEntry
497
+ ? await realpath(resolve(this.config.projectRoot, firstEntry))
498
+ : this.config.projectRoot
499
+ const compilerProject = await this.#loadCompilerProject(
500
+ options,
501
+ importer,
502
+ diagnostics
503
+ )
504
+ const cache = new MetroCompilerCache(this.cacheRootFor(options.platform))
505
+ const validation = await cache.validate()
506
+ const projectSources = await walkProjectSources(this.config.projectRoot)
507
+ const optionsHash = scanOptionsHash(
508
+ options,
509
+ compilerProject.generation,
510
+ contentHash(JSON.stringify(projectSources))
511
+ )
512
+ if (
513
+ validation.valid &&
514
+ validation.generation &&
515
+ validation.optionsHash === optionsHash &&
516
+ (await this.#sourcesAreFresh(validation.sourceHashes)) &&
517
+ ((!retainsLiveGraph(options) && !this.#graph) ||
518
+ (this.#publishedGeneration && this.#scanOptionsHash === optionsHash)) &&
519
+ // A zero build owns the one CSS artifact, and its contents are produced by
520
+ // the scan. Reusing a published plan without restoring the artifact would
521
+ // emit one missing every rule this process never collected, while still
522
+ // deriving TAMAGUI_DID_OUTPUT_CSS from it. The sidecar carries exactly
523
+ // those side effects; without it there is nothing safe to reuse.
524
+ (await this.#rehydrateZeroCSS(cache, validation.generation))
525
+ ) {
526
+ this.#publishedGeneration = validation.generation
527
+ this.#scanOptions = options
528
+ this.#scanOptionsHash = optionsHash
529
+ this.#projectGeneration = compilerProject.generation
530
+ return {
531
+ generation: validation.generation,
532
+ moduleIds: validation.moduleIds,
533
+ diagnostics,
534
+ }
535
+ }
536
+ for (const diagnostic of validation.diagnostics) this.#report(diagnostic)
537
+ await cache.discardManifest()
538
+ return await this.#scan(options, compilerProject, projectSources)
539
+ }
540
+
541
+ async updateFile(path: string): Promise<MetroCompilerUpdate> {
542
+ let result: MetroCompilerUpdate = {
543
+ changed: false,
544
+ affectedIds: [],
545
+ generation: null,
546
+ }
547
+ return this.#enqueue(async () => {
548
+ const graph = this.#graph
549
+ const options = this.#scanOptions
550
+ if (!graph || !options) return result
551
+ let record: CompiledRecord
552
+ const diagnostics: MetroCompilerDiagnostic[] = []
553
+ try {
554
+ record = await this.#compileRecord(path, options, diagnostics)
555
+ } catch (error) {
556
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
557
+ const id = resolvedModuleId(resolve(path))
558
+ const invalidation = graph.removeModule(id)
559
+ this.#watchers.get(id)?.close()
560
+ this.#watchers.delete(id)
561
+ this.#records.delete(id)
562
+ this.#entries.delete(id)
563
+ for (const affected of invalidation.invalidatedIds) {
564
+ if (affected !== id) this.#refreshEntry(affected)
565
+ }
566
+ const generation = await this.#publish(options.platform)
567
+ result = {
568
+ changed: invalidation.changed,
569
+ affectedIds: invalidation.invalidatedIds,
570
+ generation,
571
+ }
572
+ return result
573
+ }
574
+ const diagnostic = metroDiagnostic(
575
+ 'metro/transform-failed',
576
+ `Failed to update ${path}: ${error instanceof Error ? error.message : String(error)}`,
577
+ { moduleId: path }
578
+ )
579
+ this.#report(diagnostic)
580
+ return result
581
+ }
582
+
583
+ for (const dependency of record.input.imports) {
584
+ if (
585
+ dependency.external ||
586
+ !isCompilerSourceFile(dependency.resolvedId) ||
587
+ this.#records.has(dependency.resolvedId)
588
+ ) {
589
+ continue
590
+ }
591
+ await this.#addDependency(dependency.resolvedId, options, diagnostics)
592
+ }
593
+ this.#records.set(record.input.id, record)
594
+ const invalidation = graph.updateModule(record.input)
595
+ for (const affected of invalidation.invalidatedIds) this.#refreshEntry(affected)
596
+ const generation = invalidation.changed
597
+ ? await this.#publish(options.platform)
598
+ : null
599
+ result = {
600
+ changed: invalidation.changed,
601
+ affectedIds: invalidation.invalidatedIds,
602
+ generation,
603
+ }
604
+ if (this.config.watch !== false && retainsLiveGraph(options)) {
605
+ this.#watchModule(record.input.id)
606
+ }
607
+ return result
608
+ })
609
+ }
610
+
611
+ /** A published plan only applies while every recorded module source is unchanged. */
612
+ async #sourcesAreFresh(sourceHashes: Record<string, string>): Promise<boolean> {
613
+ const checks = Object.entries(sourceHashes).map(async ([moduleId, sourceHash]) => {
614
+ try {
615
+ return contentHash(await readFile(moduleId, 'utf8')) === sourceHash
616
+ } catch {
617
+ return false
618
+ }
619
+ })
620
+ return (await Promise.all(checks)).every(Boolean)
621
+ }
622
+
623
+ #enqueue<T>(operation: () => Promise<T>): Promise<T> {
624
+ const queued = this.#operationQueue.then(operation)
625
+ this.#operationQueue = queued.then(
626
+ () => undefined,
627
+ () => undefined
628
+ )
629
+ return queued
630
+ }
631
+
632
+ close(): Promise<void> {
633
+ return this.#enqueue(async () => {
634
+ this.#releaseGraph()
635
+ })
636
+ }
637
+
638
+ #releaseGraph(): void {
639
+ for (const watcher of this.#watchers.values()) watcher.close()
640
+ this.#watchers.clear()
641
+ this.#entries.clear()
642
+ this.#records.clear()
643
+ this.#planKeys.clear()
644
+ this.#graph = null
645
+ this.#host = null
646
+ this.#projectGeneration = null
647
+ }
648
+
649
+ async #loadCompilerProject(
650
+ options: MetroCompilerScanOptions,
651
+ importer: string,
652
+ diagnostics: MetroCompilerDiagnostic[]
653
+ ): Promise<MetroCompilerProject> {
654
+ const target = compilerTarget(options.platform)
655
+ if (this.config.loadCompilerProject) {
656
+ return await this.config.loadCompilerProject(target, options.platform)
657
+ }
658
+ return Static.loadCompilerProject({
659
+ root: this.config.projectRoot,
660
+ target,
661
+ options: this.config.tamaguiOptions ?? {},
662
+ hostVersions: compilerImplementationVersions,
663
+ missingProjectMessage: 'Unable to load the Tamagui project for Metro compilation',
664
+ generation: (projectInfo, componentModules, normalizedOptions) => {
665
+ return contentHash(
666
+ JSON.stringify({
667
+ cacheVersion: METRO_COMPILER_CACHE_VERSION,
668
+ compilerImplementationVersions,
669
+ componentModules,
670
+ configCss: projectInfo.tamaguiConfig?.getCSS?.() ?? '',
671
+ disablePartialExtraction: !!normalizedOptions.disablePartialExtraction,
672
+ experimentalNativeFastPath:
673
+ target === 'native' &&
674
+ normalizedOptions.experimental?.nativeFastPath === true,
675
+ target,
676
+ // the host's diagnostics are mode-aware, so a plan built in one mode is
677
+ // not a plan the other mode may reuse
678
+ zeroRuntime: !!this.config.zero,
679
+ })
680
+ )
681
+ },
682
+ resolveComponents: async (moduleNames) => {
683
+ const componentModules: MetroCompilerProject['componentModules'] = []
684
+ for (const moduleName of moduleNames) {
685
+ try {
686
+ const resolution = this.#resolver.resolve(
687
+ importer,
688
+ { specifier: moduleName, isESMImport: true },
689
+ options.platform
690
+ )
691
+ if (!resolution) continue
692
+ componentModules.push({ moduleName, id: resolution.resolvedId })
693
+ } catch (error) {
694
+ const diagnostic = metroDiagnostic(
695
+ 'metro/resolve-failed',
696
+ `Failed to resolve compiler component ${moduleName}: ${error instanceof Error ? error.message : String(error)}`,
697
+ { moduleId: importer, dependency: moduleName }
698
+ )
699
+ diagnostics.push(diagnostic)
700
+ this.#report(diagnostic)
701
+ }
702
+ }
703
+ return componentModules
704
+ },
705
+ })
706
+ }
707
+
708
+ async #compileRecord(
709
+ rawPath: string,
710
+ options: MetroCompilerScanOptions,
711
+ diagnostics: MetroCompilerDiagnostic[]
712
+ ): Promise<CompiledRecord> {
713
+ const path = await realpath(resolve(rawPath))
714
+ const source = await readFile(path, 'utf8')
715
+ const sourceHash = contentHash(source)
716
+ const id = resolvedModuleId(path)
717
+ const cache = this.#recordCache
718
+ const identity = this.#recordCacheIdentity
719
+ const key = cache && identity ? contentHash(`${identity}\0${sourceHash}`) : null
720
+ if (cache && key) {
721
+ const cached = await cache.read(key, (value) => {
722
+ const entry = value as CachedRecord | null
723
+ return entry?.schemaVersion === METRO_RECORD_CACHE_VERSION &&
724
+ entry.sourceHash === sourceHash &&
725
+ Array.isArray(entry.imports) &&
726
+ Array.isArray(entry.requireSpecifiers) &&
727
+ Array.isArray(entry.diagnostics)
728
+ ? entry
729
+ : null
730
+ })
731
+ if (cached) {
732
+ for (const diagnostic of cached.diagnostics) {
733
+ diagnostics.push(diagnostic)
734
+ this.#report(diagnostic)
735
+ }
736
+ return {
737
+ input: { id, source, imports: cached.imports },
738
+ sourceHash,
739
+ requireSpecifiers: cached.requireSpecifiers,
740
+ }
741
+ }
742
+ }
743
+
744
+ const args = this.#babelArgs(path, source, options)
745
+ const compiled = await compileWithUserBabel(
746
+ this.config.originalBabelTransformerPath,
747
+ args
748
+ )
749
+ const imports: HostResolvedImport[] = []
750
+ const requireSpecifiers: string[] = []
751
+ const recordDiagnostics: MetroCompilerDiagnostic[] = []
752
+ for (const dependency of moduleSpecifiersFromAst(compiled.result.ast)) {
753
+ if (!dependency.isESMImport) requireSpecifiers.push(dependency.specifier)
754
+ try {
755
+ const resolution = this.#resolver.resolve(path, dependency, options.platform)
756
+ if (!resolution) continue
757
+ imports.push({
758
+ specifier: resolution.specifier,
759
+ resolvedId: resolvedModuleId(resolution.resolvedId),
760
+ external: resolution.external,
761
+ })
762
+ } catch (error) {
763
+ recordDiagnostics.push(
764
+ metroDiagnostic(
765
+ 'metro/resolve-failed',
766
+ `Failed to resolve ${dependency.specifier} from ${path}: ${error instanceof Error ? error.message : String(error)}`,
767
+ { moduleId: path, dependency: dependency.specifier }
768
+ )
769
+ )
770
+ }
771
+ }
772
+ for (const diagnostic of recordDiagnostics) {
773
+ diagnostics.push(diagnostic)
774
+ this.#report(diagnostic)
775
+ }
776
+ if (cache && key) {
777
+ await cache.write(key, {
778
+ schemaVersion: METRO_RECORD_CACHE_VERSION,
779
+ sourceHash,
780
+ imports,
781
+ requireSpecifiers,
782
+ diagnostics: recordDiagnostics,
783
+ } satisfies CachedRecord)
784
+ }
785
+ return {
786
+ // The graph and plans operate on raw source: workers apply plan edits to
787
+ // the raw module before their own Babel pass, so plans never depend on
788
+ // this process's Babel output matching the workers' byte for byte.
789
+ input: { id, source, imports },
790
+ sourceHash,
791
+ requireSpecifiers,
792
+ }
793
+ }
794
+
795
+ #babelOptions(options: MetroCompilerScanOptions): MetroBabelTransformArgs['options'] {
796
+ const transformer = this.config.transformer ?? {}
797
+ return {
798
+ ...options.transform,
799
+ dev: options.dev,
800
+ hot: options.hot,
801
+ platform: options.platform,
802
+ projectRoot: this.config.projectRoot,
803
+ enableBabelRCLookup: transformer.enableBabelRCLookup ?? true,
804
+ enableBabelRuntime: transformer.enableBabelRuntime ?? true,
805
+ hermesParser: transformer.hermesParser ?? false,
806
+ publicPath: transformer.publicPath ?? '/assets',
807
+ }
808
+ }
809
+
810
+ #babelArgs(
811
+ filename: string,
812
+ src: string,
813
+ options: MetroCompilerScanOptions
814
+ ): MetroBabelTransformArgs {
815
+ return { filename, src, plugins: [], options: this.#babelOptions(options) }
816
+ }
817
+
818
+ async #addDependency(
819
+ id: ResolvedModuleId,
820
+ options: MetroCompilerScanOptions,
821
+ diagnostics: MetroCompilerDiagnostic[],
822
+ visiting = new Set<ResolvedModuleId>()
823
+ ): Promise<void> {
824
+ if (this.#records.has(id) || visiting.has(id)) return
825
+ visiting.add(id)
826
+ try {
827
+ const record = await this.#compileRecord(id, options, diagnostics)
828
+ for (const dependency of record.input.imports) {
829
+ if (!dependency.external && isCompilerSourceFile(dependency.resolvedId)) {
830
+ await this.#addDependency(dependency.resolvedId, options, diagnostics, visiting)
831
+ }
832
+ }
833
+ this.#records.set(id, record)
834
+ const invalidation = this.#graph?.updateModule(record.input)
835
+ for (const affected of invalidation?.invalidatedIds ?? [id]) {
836
+ this.#refreshEntry(affected)
837
+ }
838
+ if (
839
+ this.config.watch !== false &&
840
+ this.#scanOptions &&
841
+ retainsLiveGraph(this.#scanOptions)
842
+ ) {
843
+ this.#watchModule(id)
844
+ }
845
+ } finally {
846
+ visiting.delete(id)
847
+ }
848
+ }
849
+
850
+ #refreshEntry(id: ResolvedModuleId): void {
851
+ const graph = this.#graph
852
+ const host = this.#host
853
+ const record = this.#records.get(id)
854
+ if (!graph || !host || !record || !this.#scanOptions || !this.#projectGeneration)
855
+ return
856
+ const target = compilerTarget(this.#scanOptions.platform)
857
+ const plan = lowerModule({
858
+ module: materializeModule(graph, id),
859
+ source: record.input.source,
860
+ target,
861
+ host,
862
+ options: { projectGeneration: this.#projectGeneration },
863
+ })
864
+ // Zero-mode reference erasure rides the same plan. Metro fixes a module's
865
+ // dependencies at resolution time and does no export-level shaking, so the
866
+ // plan a worker applies before Babel is the only point early enough to
867
+ // remove an import from the graph.
868
+ const zeroPlan = this.#zeroPlanFor(id, record.input.source, plan)
869
+ this.#entries.set(id, this.#entryFor(id, record, zeroPlan ?? plan))
870
+ }
871
+
872
+ /**
873
+ * One plan becomes one cache entry the same way whether the plan was just
874
+ * lowered or read back off disk, so a restored build reports exactly the
875
+ * diagnostics a fresh one did.
876
+ */
877
+ #entryFor(
878
+ id: ResolvedModuleId,
879
+ record: CompiledRecord,
880
+ plan: LoweredModulePlan
881
+ ): MetroCompilerCacheEntry {
882
+ return {
883
+ schemaVersion: METRO_COMPILER_CACHE_VERSION,
884
+ moduleId: id,
885
+ sourceHash: record.sourceHash,
886
+ plan,
887
+ diagnostics: plan.diagnostics.map(
888
+ ({ code, message, dependencyId, span, component }) => {
889
+ const { line, column } = Static.offsetToLineColumn(
890
+ record.input.source,
891
+ span.start
892
+ )
893
+ return metroDiagnostic(
894
+ code.startsWith('linked/')
895
+ ? 'metro/resolve-failed'
896
+ : 'metro/transform-failed',
897
+ message,
898
+ { moduleId: id, dependency: dependencyId, span, line, column, component }
899
+ )
900
+ }
901
+ ),
902
+ }
903
+ }
904
+
905
+ /**
906
+ * Both per-file caches for this scan. A project with no content stamp gets
907
+ * neither: a stamp that cannot see a config change would serve styles built
908
+ * against the old config, so the answer is no cache rather than a partial one.
909
+ *
910
+ * Zero builds opt out of the plan cache because a zero plan is produced
911
+ * alongside side effects that do not travel in the plan - the CSS artifact,
912
+ * the bridge manifest, the violation list - so replaying one module's plan
913
+ * without them would emit an artifact missing its rules.
914
+ */
915
+ #installCaches(
916
+ options: MetroCompilerScanOptions,
917
+ project: MetroCompilerProject,
918
+ projectSourcesHash: string
919
+ ): void {
920
+ const platform = options.platform ?? 'default'
921
+ const root = defaultPlanCacheRoot(this.config.projectRoot, platform)
922
+ this.#recordCache = new JsonFileCache(
923
+ join(root, 'records'),
924
+ METRO_RECORD_CACHE_VERSION
925
+ )
926
+ this.#recordCacheIdentity = contentHash(
927
+ stableStringify({
928
+ schema: METRO_RECORD_CACHE_VERSION,
929
+ resolver: this.#resolver.version,
930
+ babel: userBabelCacheKey(this.config.originalBabelTransformerPath),
931
+ // resolutions depend on which files exist, so the walked source list is
932
+ // part of a record's identity exactly as it is for the plan manifest
933
+ projectSourcesHash,
934
+ platform,
935
+ transform: this.#babelOptions(options),
936
+ })
937
+ )
938
+ const stamp = project.cacheStamp
939
+ const usePlanCache = typeof stamp === 'string' && stamp !== '' && !this.config.zero
940
+ this.#planCache = usePlanCache ? new ModulePlanCache(join(root, 'plans')) : null
941
+ this.#planCacheStamp = usePlanCache ? stamp : null
942
+ }
943
+
944
+ /**
945
+ * Fills `#entries` from disk for every module whose whole compile input is
946
+ * unchanged, and returns the ids that still have to be compiled. This is the
947
+ * per-file property: one edited module leaves every other module's entry
948
+ * valid, where the plan manifest would have discarded all of them.
949
+ */
950
+ async #restorePlans(options: MetroCompilerScanOptions): Promise<ResolvedModuleId[]> {
951
+ this.#planKeys.clear()
952
+ const cache = this.#planCache
953
+ const stamp = this.#planCacheStamp
954
+ if (!cache || !stamp) return [...this.#records.keys()].sort(compareCodeUnits)
955
+ const target = compilerTarget(options.platform)
956
+ const identity = {
957
+ stamp,
958
+ target,
959
+ structuralPassHash: `${target}-noop-v1`,
960
+ }
961
+ const nodes = new Map<ResolvedModuleId, ModuleClosureNode | null>()
962
+ const lookup = (id: ResolvedModuleId): ModuleClosureNode | null => {
963
+ let node = nodes.get(id)
964
+ if (node === undefined) {
965
+ const record = this.#records.get(id)
966
+ node = record ? moduleClosureNode(record.input) : null
967
+ nodes.set(id, node)
968
+ }
969
+ return node
970
+ }
971
+ const memo = new Map<ResolvedModuleId, string | null>()
972
+ const unplanned: ResolvedModuleId[] = []
973
+ for (const id of [...this.#records.keys()].sort(compareCodeUnits)) {
974
+ const record = this.#records.get(id)!
975
+ const digest = moduleClosureDigest(id, lookup, memo)
976
+ const key = digest && planCacheKey(identity, id, digest)
977
+ const entry = key && digest ? await cache.read(key, id, digest) : null
978
+ if (entry) {
979
+ this.#entries.set(id, this.#entryFor(id, record, entry.plan))
980
+ continue
981
+ }
982
+ if (key && digest) this.#planKeys.set(id, { key, digest })
983
+ unplanned.push(id)
984
+ }
985
+ return unplanned
986
+ }
987
+
988
+ async #storePlans(ids: readonly ResolvedModuleId[]): Promise<void> {
989
+ const cache = this.#planCache
990
+ if (!cache) return
991
+ const pending = ids.flatMap((id) => {
992
+ const entry = this.#entries.get(id)
993
+ const key = this.#planKeys.get(id)
994
+ return entry && key ? [{ id, entry, key }] : []
995
+ })
996
+ // a first build writes one file per module, and doing that serially costs
997
+ // seconds on a real project
998
+ for (let index = 0; index < pending.length; index += 32) {
999
+ await Promise.all(
1000
+ pending.slice(index, index + 32).map(({ id, entry, key }) =>
1001
+ cache.write(key.key, {
1002
+ schemaVersion: PLAN_CACHE_SCHEMA_VERSION,
1003
+ moduleId: id,
1004
+ closureDigest: key.digest,
1005
+ plan: entry.plan,
1006
+ })
1007
+ )
1008
+ )
1009
+ }
1010
+ }
1011
+
1012
+ /** Modules reachable from the bundle's entry, over the frontend's own graph. */
1013
+ #reachableFrom(roots: readonly ResolvedModuleId[]): Set<ResolvedModuleId> {
1014
+ const reached = new Set<ResolvedModuleId>()
1015
+ const queue = [...roots]
1016
+ while (queue.length) {
1017
+ const id = queue.pop()!
1018
+ if (reached.has(id)) continue
1019
+ reached.add(id)
1020
+ for (const dependency of this.#records.get(id)?.input.imports ?? []) {
1021
+ if (!dependency.external) queue.push(dependency.resolvedId)
1022
+ }
1023
+ }
1024
+ return reached
1025
+ }
1026
+
1027
+ /**
1028
+ * The zero transform for one module, returning a plan whose edits also carry
1029
+ * the static Theme lowering, the island bridge, and reference erasure.
1030
+ */
1031
+ #zeroPlanFor(
1032
+ id: ResolvedModuleId,
1033
+ source: string,
1034
+ plan: ReturnType<typeof lowerModule>
1035
+ ): ReturnType<typeof lowerModule> | null {
1036
+ const zero = this.config.zero
1037
+ const config = this.#tamaguiConfig
1038
+ if (!zero || !config) return null
1039
+
1040
+ // An island build is a full-runtime graph: it contributes its compiler
1041
+ // atomic CSS to the one artifact and is never erased or judged.
1042
+ if (zero.islandBuild) {
1043
+ zero.artifact.setIslandModuleCSS(zero.islandBuild, id, plan.css)
1044
+ return null
1045
+ }
1046
+
1047
+ if (this.#zeroEntryGraph && !this.#zeroEntryGraph.has(id)) return null
1048
+ // only app-authored modules: a workspace dependency resolves outside
1049
+ // node_modules here, and erasing Tamagui's own re-exports would break it
1050
+ const relativePath = relative(this.config.projectRoot, id)
1051
+ if (
1052
+ relativePath === '' ||
1053
+ relativePath.startsWith('..') ||
1054
+ relativePath.split(/[\\/]/).includes('node_modules')
1055
+ ) {
1056
+ return null
1057
+ }
1058
+
1059
+ const result = Static.transformZeroModule({
1060
+ mode: zero.isEnforcing ? 'enforce' : 'report',
1061
+ id,
1062
+ root: this.config.projectRoot,
1063
+ source,
1064
+ plan,
1065
+ config,
1066
+ isTamaguiSpecifier: (specifier) =>
1067
+ specifier === 'tamagui' || specifier.startsWith('@tamagui/'),
1068
+ resolveIslandLoader: (specifier) => {
1069
+ const islandId = zero.loaderIds.get(zeroModuleKey(resolve(id, '..', specifier)))
1070
+ return islandId ? { islandId } : null
1071
+ },
1072
+ resolveIslandModule: (specifier) =>
1073
+ zero.islandModuleIds.get(zeroModuleKey(resolve(id, '..', specifier))) ?? null,
1074
+ })
1075
+
1076
+ zero.transformed.add(id)
1077
+ if (result.erased.exports.length) {
1078
+ zero.erasedExports.set(id, result.erased.exports)
1079
+ }
1080
+ for (const violation of result.violations) {
1081
+ const { line, column } = Static.offsetToLineColumn(source, violation.span.start)
1082
+ zero.violations.push({
1083
+ file: relativePath,
1084
+ line,
1085
+ column,
1086
+ rule: violation.rule,
1087
+ code: violation.code,
1088
+ component: violation.component,
1089
+ message: violation.message,
1090
+ })
1091
+ }
1092
+ if (result.violations.length || !zero.isEnforcing) return null
1093
+
1094
+ Static.mergeIslandBridges(zero.bridges, result.bridges)
1095
+ for (const [identifier, rules] of result.bridgeCSS) {
1096
+ zero.artifact.setBridgeRules(identifier, rules)
1097
+ }
1098
+ zero.artifact.setZeroModuleCSS(id, plan.css)
1099
+ return { ...plan, edits: [...plan.edits, ...result.edits] }
1100
+ }
1101
+
1102
+ async #publish(platform: string | null): Promise<string> {
1103
+ const cache = new MetroCompilerCache(this.cacheRootFor(platform))
1104
+ const generation = await cache.publish(
1105
+ platform,
1106
+ [...this.#entries.values()],
1107
+ this.#scanOptionsHash ?? ''
1108
+ )
1109
+ const zero = this.config.zero
1110
+ if (zero && !zero.islandBuild) {
1111
+ // the plans and the artifact are the same scan's output, so they are
1112
+ // published together or the warm path has nothing safe to reuse
1113
+ await cache.publishZeroCSS({
1114
+ schemaVersion: METRO_COMPILER_CACHE_VERSION,
1115
+ generation,
1116
+ configCSS: zero.configCSS,
1117
+ zeroModuleCSS: Object.fromEntries(zero.artifact.zeroModuleEntries()),
1118
+ bridgeCSS: Object.fromEntries(zero.artifact.bridgeEntries()),
1119
+ bridges: Object.fromEntries(zero.bridges),
1120
+ })
1121
+ }
1122
+ this.#publishedGeneration = generation
1123
+ return generation
1124
+ }
1125
+
1126
+ /**
1127
+ * Restores the zero build's CSS side effects from the sidecar published with
1128
+ * this plan generation. Returns false when there is nothing trustworthy to
1129
+ * restore, which sends the caller to a full scan.
1130
+ */
1131
+ async #rehydrateZeroCSS(cache: MetroCompilerCache, generation: string) {
1132
+ const zero = this.config.zero
1133
+ if (!zero || zero.islandBuild) return true
1134
+ const sidecar = await cache.readZeroCSS(generation)
1135
+ if (!sidecar) return false
1136
+ zero.artifact.clearGraphs()
1137
+ zero.bridges.clear()
1138
+ zero.violations.length = 0
1139
+ zero.configCSS = sidecar.configCSS
1140
+ for (const [moduleId, css] of Object.entries(sidecar.zeroModuleCSS)) {
1141
+ zero.artifact.setZeroModuleCSS(moduleId, css)
1142
+ }
1143
+ for (const [bridgeId, css] of Object.entries(sidecar.bridgeCSS)) {
1144
+ zero.artifact.setBridgeRules(bridgeId, css)
1145
+ }
1146
+ for (const [islandId, bridges] of Object.entries(sidecar.bridges)) {
1147
+ zero.bridges.set(islandId, bridges as IslandThemeBridge[])
1148
+ }
1149
+ zero.plansRestoredFromCache = true
1150
+ return true
1151
+ }
1152
+
1153
+ #installWatchers(): void {
1154
+ for (const id of this.#records.keys()) this.#watchModule(id)
1155
+ }
1156
+
1157
+ #watchModule(id: ResolvedModuleId): void {
1158
+ if (this.#watchers.has(id)) return
1159
+ try {
1160
+ const watcher = watch(id, { persistent: false }, () => {
1161
+ void this.updateFile(id)
1162
+ })
1163
+ watcher.unref()
1164
+ this.#watchers.set(id, watcher)
1165
+ } catch {
1166
+ // A concurrent delete is handled by the importer's next invalidation.
1167
+ }
1168
+ }
1169
+
1170
+ #report(diagnostic: MetroCompilerDiagnostic): void {
1171
+ this.config.reportDiagnostic?.(diagnostic)
1172
+ }
1173
+ }
1174
+
1175
+ export function describeMetroCompilerRoot(projectRoot: string, moduleId: string): string {
1176
+ const path = relative(projectRoot, moduleId)
1177
+ return path.startsWith('..') ? basename(moduleId) : path
1178
+ }