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