@tamagui/cli 2.7.7 → 3.0.0-beta.1093.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.
package/src/build.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
- createExtractor,
3
- extractToClassNames,
4
- extractToNative,
2
+ CompilerFrontend,
5
3
  loadTamagui,
6
4
  loadTamaguiBuildConfigSync,
5
+ compilerProjectStamp,
6
+ type CompilerProject,
7
7
  } from '@tamagui/static'
8
8
  import type { CLIResolvedOptions, TamaguiOptions } from '@tamagui/types'
9
9
  import chokidar from 'chokidar'
@@ -11,8 +11,22 @@ import { copyFile, mkdir, readFile, rm, stat, writeFile } from 'fs-extra'
11
11
  import MicroMatch from 'micromatch'
12
12
  import { basename, dirname, extname, join, relative, resolve } from 'node:path'
13
13
  import { tmpdir } from 'node:os'
14
- import { execSync } from 'node:child_process'
14
+ import { spawn } from 'node:child_process'
15
15
  import { createHash } from 'node:crypto'
16
+ import { createRequire } from 'node:module'
17
+ import {
18
+ findConfigFile,
19
+ nodeModuleNameResolver,
20
+ parseJsonConfigFileContent,
21
+ readConfigFile,
22
+ sys,
23
+ } from 'typescript'
24
+
25
+ const cliVersion = (
26
+ createRequire(typeof __filename === 'string' ? __filename : import.meta.url)(
27
+ '@tamagui/cli/package.json'
28
+ ) as { version: string }
29
+ ).version
16
30
 
17
31
  export type BuildStats = {
18
32
  filesProcessed: number
@@ -20,6 +34,7 @@ export type BuildStats = {
20
34
  flattened: number
21
35
  styled: number
22
36
  found: number
37
+ bailed: number
23
38
  }
24
39
 
25
40
  export type TrackedFile = {
@@ -68,418 +83,635 @@ export const build = async (
68
83
  const outputAround = options.outputAround || false
69
84
  const promises: Promise<void>[] = []
70
85
  const isDryRun = options.dryRun || false
86
+ const trackedFiles: TrackedFile[] = []
87
+ const trackedPaths = new Set<string>()
88
+ const restoreDir = options.runCommand
89
+ ? join(tmpdir(), `tamagui-restore-${process.pid}`)
90
+ : null
71
91
 
72
- if (isDryRun) {
73
- console.info('[dry-run] no files will be written\n')
92
+ if (restoreDir) {
93
+ await mkdir(restoreDir, { recursive: true })
74
94
  }
75
-
76
- // create output directory if specified
77
- if (outputDir) {
78
- await mkdir(outputDir, { recursive: true })
95
+ let didRestore = false
96
+ const restoreTrackedFiles = async (force = false) => {
97
+ if (didRestore) return
98
+ await restoreFiles(trackedFiles, restoreDir, force)
99
+ didRestore = true
79
100
  }
80
101
 
81
- const loadedOptions = loadTamaguiBuildConfigSync(options.tamaguiOptions)
102
+ try {
103
+ const trackFile = async (filePath: string): Promise<void> => {
104
+ if (!restoreDir || trackedPaths.has(filePath)) return
105
+ trackedPaths.add(filePath)
82
106
 
83
- // when running CLI build directly, ignore disable since user explicitly wants to build
84
- if (loadedOptions.disable) {
85
- console.warn(
86
- `[tamagui] Note: "disable" option in tamagui.build.ts is being ignored for CLI build command`
87
- )
88
- }
89
- const buildOptions = {
90
- ...loadedOptions,
91
- disable: false,
92
- disableExtraction: false,
93
- }
107
+ const currentStat = await stat(filePath).catch(() => null)
108
+ if (!currentStat) {
109
+ trackedFiles.push({ path: filePath, hardlinkPath: '', mtimeAfterWrite: 0 })
110
+ return
111
+ }
94
112
 
95
- const targets =
96
- options.target === 'both' || !options.target
97
- ? (['web', 'native'] as const)
98
- : ([options.target] as const)
113
+ const hash = createHash('md5').update(filePath).digest('hex')
114
+ const backupPath = join(restoreDir, hash)
115
+ await copyFile(filePath, backupPath)
116
+ trackedFiles.push({ path: filePath, hardlinkPath: backupPath, mtimeAfterWrite: 0 })
117
+ }
99
118
 
100
- // Load tamagui for web first (needed for both targets)
101
- const webTamaguiOptions = {
102
- ...buildOptions,
103
- platform: 'web' as const,
104
- } satisfies TamaguiOptions
119
+ const recordMtime = async (filePath: string): Promise<void> => {
120
+ if (!restoreDir) return
121
+ const tracked = trackedFiles.find((item) => item.path === filePath)
122
+ if (tracked) {
123
+ const fileStat = await stat(filePath)
124
+ tracked.mtimeAfterWrite = fileStat.mtimeMs
125
+ }
126
+ }
105
127
 
106
- await loadTamagui(webTamaguiOptions)
128
+ if (isDryRun) {
129
+ console.info('[dry-run] no files will be written\n')
130
+ }
107
131
 
108
- // Collect all files first
109
- const allFiles: string[] = []
132
+ // create output directory if specified
133
+ if (outputDir) {
134
+ await mkdir(outputDir, { recursive: true })
135
+ }
110
136
 
111
- // Handle both directory and specific file paths
112
- const watchPattern = sourceDir.match(/\.(tsx|jsx)$/)
113
- ? sourceDir // Single file
114
- : `${sourceDir}/**/*.{tsx,jsx}` // Directory
137
+ const loadedOptions = loadTamaguiBuildConfigSync(options.tamaguiOptions)
115
138
 
116
- await new Promise<void>((res) => {
117
- const watcher = chokidar.watch(watchPattern, {
118
- ignoreInitial: false,
119
- })
120
- watcher
121
- .on('add', (relativePath) => {
122
- const sourcePath = resolve(process.cwd(), relativePath)
139
+ // when running CLI build directly, ignore disable since user explicitly wants to build
140
+ if (loadedOptions.disable) {
141
+ console.warn(
142
+ `[tamagui] Note: "disable" option in tamagui.build.ts is being ignored for CLI build command`
143
+ )
144
+ }
145
+ const buildOptions = {
146
+ ...loadedOptions,
147
+ disable: false,
148
+ disableExtraction: false,
149
+ }
123
150
 
124
- if (options.exclude && MicroMatch.contains(relativePath, options.exclude)) {
125
- return
126
- }
127
- if (options.include && !MicroMatch.contains(relativePath, options.include)) {
128
- return
129
- }
151
+ const targets =
152
+ options.target === 'both' || !options.target
153
+ ? (['web', 'native'] as const)
154
+ : ([options.target] as const)
155
+
156
+ const root = process.cwd()
157
+ const outputCSSPath = buildOptions.outputCSS
158
+ ? resolve(root, buildOptions.outputCSS)
159
+ : null
160
+ if (outputCSSPath) {
161
+ await trackFile(outputCSSPath)
162
+ }
163
+ const require = createRequire(
164
+ typeof __filename === 'string' ? __filename : import.meta.url
165
+ )
166
+ const configPath =
167
+ findConfigFile(root, sys.fileExists, 'tsconfig.json') ||
168
+ findConfigFile(root, sys.fileExists, 'jsconfig.json')
169
+ const compilerOptions = configPath
170
+ ? parseJsonConfigFileContent(
171
+ (() => {
172
+ const loaded = readConfigFile(configPath, sys.readFile)
173
+ if (loaded.error) throw new Error(String(loaded.error.messageText))
174
+ return loaded.config
175
+ })(),
176
+ sys,
177
+ dirname(configPath)
178
+ ).options
179
+ : {}
180
+ const resolveCompilerId = (specifier: string, importer: string): string | null => {
181
+ const resolved = nodeModuleNameResolver(specifier, importer, compilerOptions, sys)
182
+ .resolvedModule?.resolvedFileName
183
+ if (resolved && !resolved.endsWith('.d.ts')) return resolved
184
+ try {
185
+ return require.resolve(specifier, { paths: [dirname(importer), root] })
186
+ } catch {
187
+ return null
188
+ }
189
+ }
190
+ const compilerFrontends = new Map<'web' | 'native', CompilerFrontend>()
191
+ const compilerProjects = new Map<'web' | 'native', CompilerProject>()
192
+
193
+ for (const target of targets) {
194
+ const targetOptions = { ...buildOptions, platform: target } satisfies TamaguiOptions
195
+ const projectInfo = await loadTamagui(targetOptions)
196
+ if (!projectInfo) throw new Error(`Unable to load Tamagui for the ${target} build`)
197
+ const componentModules = [
198
+ ...new Set(['@tamagui/core', ...(targetOptions.components ?? [])]),
199
+ ].map((moduleName) => {
200
+ const id = resolveCompilerId(moduleName, join(root, '__tamagui_cli__.tsx'))
201
+ if (!id) throw new Error(`Unable to resolve compiler component ${moduleName}`)
202
+ return { moduleName, id }
203
+ })
204
+ compilerProjects.set(target, {
205
+ projectInfo,
206
+ componentModules,
207
+ generation: createHash('sha256')
208
+ .update(target)
209
+ .update('\0')
210
+ .update(buildOptions.config ?? 'tamagui.config.ts')
211
+ .update('\0')
212
+ .update(JSON.stringify(targetOptions.components ?? []))
213
+ .update('\0')
214
+ .update(String(!!targetOptions.disablePartialExtraction))
215
+ .update('\0')
216
+ .update(
217
+ String(target === 'native' && !!targetOptions.experimental?.nativeFastPath)
218
+ )
219
+ .digest('hex'),
220
+ disablePartialExtraction: targetOptions.disablePartialExtraction,
221
+ experimentalNativeFastPath:
222
+ target === 'native' && targetOptions.experimental?.nativeFastPath === true,
223
+ cacheStamp: compilerProjectStamp({
224
+ stampSources: projectInfo.stampSources ?? [],
225
+ hostVersions: [`@tamagui/cli@${cliVersion}`],
226
+ target,
227
+ componentModules,
228
+ disablePartialExtraction: !!targetOptions.disablePartialExtraction,
229
+ experimentalNativeFastPath:
230
+ target === 'native' && targetOptions.experimental?.nativeFastPath === true,
231
+ zeroRuntime: false,
232
+ development: process.env.NODE_ENV === 'development',
233
+ }),
234
+ })
235
+ compilerFrontends.set(target, new CompilerFrontend())
236
+ }
237
+ if (outputCSSPath) {
238
+ await recordMtime(outputCSSPath)
239
+ }
130
240
 
131
- allFiles.push(sourcePath)
241
+ const compileTarget = async (
242
+ target: 'web' | 'native',
243
+ sourcePath: string,
244
+ source: string
245
+ ) => {
246
+ const compiler = compilerFrontends.get(target)!
247
+ const project = compilerProjects.get(target)!
248
+ return compiler.compile({
249
+ id: sourcePath,
250
+ source,
251
+ root,
252
+ target,
253
+ project,
254
+ resolve: async (specifier, importer) => {
255
+ const id = resolveCompilerId(specifier, importer)
256
+ return id ? { id, external: id.includes('/node_modules/') } : null
257
+ },
258
+ load: async (id) => {
259
+ try {
260
+ return await readFile(id.split(/[?#]/, 1)[0]!, 'utf8')
261
+ } catch {
262
+ return null
263
+ }
264
+ },
132
265
  })
133
- .on('ready', () => {
134
- watcher.close().then(() => res())
266
+ }
267
+
268
+ // Collect all files first
269
+ const allFiles: string[] = []
270
+
271
+ // Handle both directory and specific file paths
272
+ const watchPattern = sourceDir.match(/\.(tsx|jsx)$/)
273
+ ? sourceDir // Single file
274
+ : `${sourceDir}/**/*.{tsx,jsx}` // Directory
275
+ const sourceRoot = sourceDir.match(/\.(tsx|jsx)$/)
276
+ ? dirname(resolve(sourceDir))
277
+ : resolve(sourceDir)
278
+
279
+ await new Promise<void>((res) => {
280
+ const watcher = chokidar.watch(watchPattern, {
281
+ ignoreInitial: false,
135
282
  })
136
- })
283
+ watcher
284
+ .on('add', (relativePath) => {
285
+ const sourcePath = resolve(process.cwd(), relativePath)
137
286
 
138
- // Now determine what to optimize for each file
139
- const fileToTargets = new Map<string, ('web' | 'native')[]>()
287
+ if (options.exclude && MicroMatch.contains(relativePath, options.exclude)) {
288
+ return
289
+ }
290
+ if (options.include && !MicroMatch.contains(relativePath, options.include)) {
291
+ return
292
+ }
140
293
 
141
- for (const sourcePath of allFiles) {
142
- const platformMatch = sourcePath.match(/\.(web|native|ios|android)\.(tsx|jsx)$/)
143
- let filePlatforms: ('web' | 'native')[] = []
294
+ allFiles.push(sourcePath)
295
+ })
296
+ .on('ready', () => {
297
+ watcher.close().then(() => res())
298
+ })
299
+ })
144
300
 
145
- if (platformMatch) {
146
- // Platform-specific file - only optimize for that platform
147
- const platform = platformMatch[1]
148
- if (platform === 'web') {
149
- filePlatforms = ['web']
150
- } else if (platform === 'native' || platform === 'ios' || platform === 'android') {
151
- filePlatforms = ['native']
301
+ // chokidar emits "add" in filesystem/stat-completion order, which varies between
302
+ // runs. sort so file processing order (and therefore output) is reproducible.
303
+ allFiles.sort()
304
+
305
+ // Now determine what to optimize for each file
306
+ const fileToTargets = new Map<string, ('web' | 'native')[]>()
307
+
308
+ for (const sourcePath of allFiles) {
309
+ const platformMatch = sourcePath.match(/\.(web|native|ios|android)\.(tsx|jsx)$/)
310
+ let filePlatforms: ('web' | 'native')[] = []
311
+
312
+ if (platformMatch) {
313
+ // Platform-specific file - only optimize for that platform
314
+ const platform = platformMatch[1]
315
+ if (platform === 'web') {
316
+ filePlatforms = ['web']
317
+ } else if (
318
+ platform === 'native' ||
319
+ platform === 'ios' ||
320
+ platform === 'android'
321
+ ) {
322
+ filePlatforms = ['native']
323
+ }
324
+ } else {
325
+ // Base file without platform extension
326
+ // Check if platform-specific versions exist in the collected files
327
+ const basePath = sourcePath.replace(/\.(tsx|jsx)$/, '')
328
+ const hasNative = allFiles.some(
329
+ (f) =>
330
+ f === `${basePath}.native.tsx` ||
331
+ f === `${basePath}.native.jsx` ||
332
+ f === `${basePath}.ios.tsx` ||
333
+ f === `${basePath}.ios.jsx` ||
334
+ f === `${basePath}.android.tsx` ||
335
+ f === `${basePath}.android.jsx`
336
+ )
337
+ const hasWeb = allFiles.some(
338
+ (f) => f === `${basePath}.web.tsx` || f === `${basePath}.web.jsx`
339
+ )
340
+
341
+ // Only optimize for targets that don't have platform-specific files
342
+ filePlatforms = targets.filter((target) => {
343
+ if (target === 'native' && hasNative) return false
344
+ if (target === 'web' && hasWeb) return false
345
+ return true
346
+ })
347
+
348
+ // Special case: if BOTH .web and .native exist, don't touch base file at all
349
+ if (hasWeb && hasNative) {
350
+ filePlatforms = []
351
+ }
152
352
  }
153
- } else {
154
- // Base file without platform extension
155
- // Check if platform-specific versions exist in the collected files
156
- const basePath = sourcePath.replace(/\.(tsx|jsx)$/, '')
157
- const hasNative = allFiles.some(
158
- (f) =>
159
- f === `${basePath}.native.tsx` ||
160
- f === `${basePath}.native.jsx` ||
161
- f === `${basePath}.ios.tsx` ||
162
- f === `${basePath}.ios.jsx` ||
163
- f === `${basePath}.android.tsx` ||
164
- f === `${basePath}.android.jsx`
165
- )
166
- const hasWeb = allFiles.some(
167
- (f) => f === `${basePath}.web.tsx` || f === `${basePath}.web.jsx`
168
- )
169
353
 
170
- // Only optimize for targets that don't have platform-specific files
171
- filePlatforms = targets.filter((target) => {
172
- if (target === 'native' && hasNative) return false
173
- if (target === 'web' && hasWeb) return false
174
- return true
175
- })
354
+ if (filePlatforms.length > 0) {
355
+ fileToTargets.set(sourcePath, filePlatforms)
356
+ }
357
+ }
358
+
359
+ // Track overall statistics
360
+ const stats: BuildStats = {
361
+ filesProcessed: 0,
362
+ optimized: 0,
363
+ flattened: 0,
364
+ styled: 0,
365
+ found: 0,
366
+ bailed: 0,
367
+ }
176
368
 
177
- // Special case: if BOTH .web and .native exist, don't touch base file at all
178
- if (hasWeb && hasNative) {
179
- filePlatforms = []
369
+ // both target passes report through here, so a `--target native` run stops
370
+ // summarizing itself as all zeros. a file counts once even when it compiles for
371
+ // both targets, otherwise `files` double-counts every shared component.
372
+ const countedFiles = new Set<string>()
373
+ const addStats = (
374
+ sourcePath: string,
375
+ fileStats: Awaited<ReturnType<typeof compileTarget>>['plan']['stats']
376
+ ) => {
377
+ if (!countedFiles.has(sourcePath)) {
378
+ countedFiles.add(sourcePath)
379
+ stats.filesProcessed++
180
380
  }
381
+ stats.optimized += fileStats.lowered - fileStats.flattened
382
+ stats.flattened += fileStats.flattened
383
+ stats.styled += fileStats.styled
384
+ stats.found += fileStats.found
385
+ stats.bailed += fileStats.bailed
181
386
  }
182
387
 
183
- if (filePlatforms.length > 0) {
184
- fileToTargets.set(sourcePath, filePlatforms)
388
+ if (options.debug) {
389
+ process.env.NODE_ENV ||= 'development'
185
390
  }
186
- }
187
391
 
188
- // Track overall statistics
189
- const stats: BuildStats = {
190
- filesProcessed: 0,
191
- optimized: 0,
192
- flattened: 0,
193
- styled: 0,
194
- found: 0,
195
- }
392
+ // Read each original source ONCE, up front, so both target passes compile
393
+ // identical input (the web pass rewrites the file on disk).
394
+ const sources = new Map<string, string>()
395
+ await Promise.all(
396
+ [...fileToTargets.keys()].map(async (sourcePath) => {
397
+ sources.set(sourcePath, await readFile(sourcePath, 'utf-8'))
398
+ })
399
+ )
196
400
 
197
- // Track files for restoration (when using --run)
198
- const trackedFiles: TrackedFile[] = []
199
- const restoreDir = options.runCommand
200
- ? join(tmpdir(), `tamagui-restore-${process.pid}`)
201
- : null
401
+ const buildWebFile = async (sourcePath: string, originalSource: string) => {
402
+ if (isDryRun) {
403
+ console.info(`\n${sourcePath} [web]`)
404
+ }
405
+ const out = await compileTarget('web', sourcePath, originalSource)
202
406
 
203
- if (restoreDir) {
204
- await mkdir(restoreDir, { recursive: true })
205
- }
407
+ if (out.output.changed || out.plan.stats.found > 0) {
408
+ addStats(sourcePath, out.plan.stats)
206
409
 
207
- // Helper to backup a file before modifying it
208
- const trackFile = async (filePath: string): Promise<void> => {
209
- if (!restoreDir) return
210
- const hash = createHash('md5').update(filePath).digest('hex')
211
- const backupPath = join(restoreDir, hash)
212
- // Use copy instead of hardlink - hardlinks share content, so modifying
213
- // the original would also modify the "backup"
214
- await copyFile(filePath, backupPath)
215
- trackedFiles.push({ path: filePath, hardlinkPath: backupPath, mtimeAfterWrite: 0 })
216
- }
410
+ if (isDryRun) {
411
+ if (out.plan.css) {
412
+ console.info(`\ncss:\n${out.plan.css}`)
413
+ }
414
+ console.info(`\njs:\n${out.output.code}`)
415
+ } else {
416
+ // compute relative path to preserve directory structure in output
417
+ const relPath = outputDir
418
+ ? relative(sourceRoot, sourcePath)
419
+ : basename(sourcePath)
420
+ const cssName = '_' + basename(sourcePath, extname(sourcePath))
421
+ const outputBase = outputDir
422
+ ? join(outputDir, dirname(relPath))
423
+ : dirname(sourcePath)
424
+
425
+ // ensure output subdirectory exists
426
+ if (outputDir) {
427
+ await mkdir(outputBase, { recursive: true })
428
+ }
217
429
 
218
- // Helper to record mtime after writing
219
- const recordMtime = async (filePath: string): Promise<void> => {
220
- if (!restoreDir) return
221
- const tracked = trackedFiles.find((t) => t.path === filePath)
222
- if (tracked) {
223
- const fileStat = await stat(filePath)
224
- tracked.mtimeAfterWrite = fileStat.mtimeMs
225
- }
226
- }
430
+ const stylePath = join(outputBase, cssName + '.css')
431
+ const cssImport = `import "./${cssName}.css"`
432
+ const code = out.plan.css
433
+ ? insertCssImport(out.output.code, cssImport)
434
+ : out.output.code
227
435
 
228
- // Process all files
229
- for (const [sourcePath, filePlatforms] of fileToTargets) {
230
- promises.push(
231
- (async () => {
232
- if (options.debug) {
233
- process.env.NODE_ENV ||= 'development'
234
- }
235
- // Read original source ONCE for both targets
236
- const originalSource = await readFile(sourcePath, 'utf-8')
436
+ // Determine output path for JS (preserve directory structure)
437
+ const webOutputPath = outputDir ? join(outputDir, relPath) : sourcePath
237
438
 
238
- if (isDryRun) {
239
- console.info(`\n${sourcePath} [${filePlatforms.join(', ')}]`)
240
- }
439
+ // Track original file before modifying (skip if using output dir)
440
+ if (!outputDir) {
441
+ await trackFile(sourcePath)
442
+ }
241
443
 
242
- // Build web version from original source
243
- if (filePlatforms.includes('web')) {
244
- process.env.TAMAGUI_TARGET = 'web'
245
- const extractor = createExtractor({
246
- platform: 'web',
247
- })
248
-
249
- const out = await extractToClassNames({
250
- extractor,
251
- source: originalSource,
252
- sourcePath,
253
- options: {
254
- ...buildOptions,
255
- platform: 'web',
256
- },
257
- shouldPrintDebug: options.debug || false,
258
- })
259
-
260
- if (out) {
261
- stats.filesProcessed++
262
- stats.optimized += out.stats.optimized
263
- stats.flattened += out.stats.flattened
264
- stats.styled += out.stats.styled
265
- stats.found += out.stats.found
266
-
267
- if (isDryRun) {
268
- const jsContent =
269
- typeof out.js === 'string' ? out.js : out.js.toString('utf-8')
270
- if (out.styles) {
271
- console.info(`\ncss:\n${out.styles}`)
272
- }
273
- console.info(`\njs:\n${jsContent}`)
274
- } else {
275
- // compute relative path to preserve directory structure in output
276
- const relPath = outputDir
277
- ? relative(resolve(sourceDir), sourcePath)
278
- : basename(sourcePath)
279
- const cssName = '_' + basename(sourcePath, extname(sourcePath))
280
- const outputBase = outputDir
281
- ? join(outputDir, dirname(relPath))
282
- : dirname(sourcePath)
283
-
284
- // ensure output subdirectory exists
285
- if (outputDir) {
286
- await mkdir(outputBase, { recursive: true })
287
- }
288
-
289
- const stylePath = join(outputBase, cssName + '.css')
290
- const cssImport = `import "./${cssName}.css"`
291
- const jsContent =
292
- typeof out.js === 'string' ? out.js : out.js.toString('utf-8')
293
- const code = insertCssImport(jsContent, cssImport)
294
-
295
- // Determine output path for JS (preserve directory structure)
296
- const webOutputPath = outputDir ? join(outputDir, relPath) : sourcePath
297
-
298
- // Track original file before modifying (skip if using output dir)
299
- if (!outputDir) {
300
- await trackFile(sourcePath)
301
- }
302
-
303
- // Write web output
304
- await writeFile(webOutputPath, code, 'utf-8')
305
- if (!outputDir) {
306
- await recordMtime(sourcePath)
307
- }
308
-
309
- // CSS file is new, track for cleanup (skip if using output dir)
310
- await writeFile(stylePath, out.styles, 'utf-8')
311
- if (!outputDir) {
312
- // Note: CSS files are new (generated), we'll delete them on restore
313
- trackedFiles.push({
314
- path: stylePath,
315
- hardlinkPath: '', // Empty means delete on restore
316
- mtimeAfterWrite: (await stat(stylePath)).mtimeMs,
317
- })
318
- }
319
- }
320
- } else if (isDryRun) {
321
- console.info(` web: no output`)
444
+ // Write web output
445
+ await writeFile(webOutputPath, code, 'utf-8')
446
+ if (!outputDir) {
447
+ await recordMtime(sourcePath)
448
+ }
449
+
450
+ // CSS file is new, track for cleanup (skip if using output dir)
451
+ if (out.plan.css) {
452
+ await writeFile(stylePath, out.plan.css, 'utf-8')
453
+ }
454
+ if (!outputDir && out.plan.css) {
455
+ // Note: CSS files are new (generated), we'll delete them on restore
456
+ trackedFiles.push({
457
+ path: stylePath,
458
+ hardlinkPath: '', // Empty means delete on restore
459
+ mtimeAfterWrite: (await stat(stylePath)).mtimeMs,
460
+ })
322
461
  }
323
462
  }
463
+ } else if (isDryRun) {
464
+ console.info(` web: no output`)
465
+ }
466
+ }
324
467
 
325
- // Build native version from original source (NOT from the web-optimized version)
326
- if (filePlatforms.includes('native')) {
327
- process.env.TAMAGUI_TARGET = 'native'
328
- const nativeTamaguiOptions = {
329
- ...buildOptions,
330
- platform: 'native' as const,
331
- } satisfies TamaguiOptions
332
-
333
- // Use the ORIGINAL source, not what was just written to disk
334
- const nativeOut = extractToNative(
335
- sourcePath,
336
- originalSource,
337
- nativeTamaguiOptions
338
- )
468
+ // Build native version from original source (NOT from the web-optimized version)
469
+ const buildNativeFile = async (
470
+ sourcePath: string,
471
+ filePlatforms: ('web' | 'native')[],
472
+ originalSource: string
473
+ ) => {
474
+ if (isDryRun) {
475
+ console.info(`\n${sourcePath} [native]`)
476
+ }
477
+ // Use the ORIGINAL source, not what was just written to disk
478
+ const nativeOut = await compileTarget('native', sourcePath, originalSource)
479
+
480
+ if (nativeOut.output.changed || nativeOut.plan.stats.found > 0) {
481
+ addStats(sourcePath, nativeOut.plan.stats)
482
+ }
339
483
 
340
- if (isDryRun) {
341
- if (nativeOut.code) {
342
- console.info(`\nnative:\n${nativeOut.code}`)
343
- } else {
344
- console.info(` native: no output`)
345
- }
346
- } else {
347
- // Determine output path:
348
- // - If --output-around, write .native.tsx next to source
349
- // - If --output specified, preserve directory structure
350
- // - If this IS a .native.tsx file, overwrite it
351
- // - If building both targets from base file, create .native.tsx
352
- // - If single native target, overwrite source
353
- let nativeOutputPath = sourcePath
354
- const isPlatformSpecific = /\.(web|native|ios|android)\.(tsx|jsx)$/.test(
355
- sourcePath
484
+ if (isDryRun) {
485
+ if (nativeOut.output.code) {
486
+ console.info(`\nnative:\n${nativeOut.output.code}`)
487
+ } else {
488
+ console.info(` native: no output`)
489
+ }
490
+ } else {
491
+ // Determine output path:
492
+ // - If --output-around, write .native.tsx next to source
493
+ // - If --output specified, preserve directory structure
494
+ // - If this IS a .native.tsx file, overwrite it
495
+ // - If building both targets from base file, create .native.tsx
496
+ // - If single native target, overwrite source
497
+ let nativeOutputPath = sourcePath
498
+ const isPlatformSpecific = /\.(web|native|ios|android)\.(tsx|jsx)$/.test(
499
+ sourcePath
500
+ )
501
+ const needsNativeSuffix =
502
+ !isPlatformSpecific && (filePlatforms.length > 1 || outputAround)
503
+
504
+ if (outputAround) {
505
+ // Output .native.tsx next to source file
506
+ nativeOutputPath = sourcePath.replace(/\.(tsx|jsx)$/, '.native.$1')
507
+ // Check if file exists - error if so
508
+ const exists = await stat(nativeOutputPath).catch(() => null)
509
+ if (exists) {
510
+ throw new Error(
511
+ `--output-around: ${nativeOutputPath} already exists. Remove it first or use --output instead.`
356
512
  )
357
- const needsNativeSuffix =
358
- !isPlatformSpecific && (filePlatforms.length > 1 || outputAround)
359
-
360
- if (outputAround) {
361
- // Output .native.tsx next to source file
362
- nativeOutputPath = sourcePath.replace(/\.(tsx|jsx)$/, '.native.$1')
363
- // Check if file exists - error if so
364
- const exists = await stat(nativeOutputPath).catch(() => null)
365
- if (exists) {
366
- throw new Error(
367
- `--output-around: ${nativeOutputPath} already exists. Remove it first or use --output instead.`
368
- )
369
- }
370
- } else if (outputDir) {
371
- // preserve directory structure in output
372
- const relPath = relative(resolve(sourceDir), sourcePath)
373
- // add .native suffix when building both targets to avoid overwriting web output
374
- const outputRelPath = needsNativeSuffix
375
- ? relPath.replace(/\.(tsx|jsx)$/, '.native.$1')
376
- : relPath
377
- nativeOutputPath = join(outputDir, outputRelPath)
378
- // ensure output subdirectory exists
379
- await mkdir(dirname(nativeOutputPath), { recursive: true })
380
- } else if (needsNativeSuffix) {
381
- // Base file building both targets - create separate .native.tsx
382
- nativeOutputPath = sourcePath.replace(/\.(tsx|jsx)$/, '.native.$1')
383
- }
384
-
385
- if (nativeOut.code) {
386
- // check if extraction actually happened by looking for our markers
387
- const hasExtraction =
388
- nativeOut.code.includes('__ReactNativeStyleSheet') ||
389
- nativeOut.code.includes('_withStableStyle')
390
- if (hasExtraction) {
391
- stats.filesProcessed++
392
- // count styled wrappers as flattened (native extraction flattens styles)
393
- const wrapperMatches = nativeOut.code.match(/_withStableStyle/g)
394
- if (wrapperMatches) {
395
- stats.flattened += wrapperMatches.length
396
- }
397
- }
398
-
399
- // Track original if overwriting existing file (skip if using output dir or outputAround)
400
- if (
401
- !outputDir &&
402
- !outputAround &&
403
- (nativeOutputPath === sourcePath || filePlatforms.length === 1)
404
- ) {
405
- await trackFile(nativeOutputPath)
406
- }
407
- await writeFile(nativeOutputPath, nativeOut.code, 'utf-8')
408
- if (!outputDir && !outputAround) {
409
- await recordMtime(nativeOutputPath)
410
- }
411
-
412
- // If creating new .native.tsx, track for deletion (skip if using output dir or outputAround)
413
- if (
414
- !outputDir &&
415
- !outputAround &&
416
- nativeOutputPath !== sourcePath &&
417
- filePlatforms.length > 1
418
- ) {
419
- trackedFiles.push({
420
- path: nativeOutputPath,
421
- hardlinkPath: '', // Empty = delete on restore
422
- mtimeAfterWrite: (await stat(nativeOutputPath)).mtimeMs,
423
- })
424
- }
425
-
426
- if (outputAround) {
427
- console.info(` → ${nativeOutputPath}`)
428
- }
429
- }
430
513
  }
514
+ } else if (outputDir) {
515
+ // preserve directory structure in output
516
+ const relPath = relative(sourceRoot, sourcePath)
517
+ // add .native suffix when building both targets to avoid overwriting web output
518
+ const outputRelPath = needsNativeSuffix
519
+ ? relPath.replace(/\.(tsx|jsx)$/, '.native.$1')
520
+ : relPath
521
+ nativeOutputPath = join(outputDir, outputRelPath)
522
+ // ensure output subdirectory exists
523
+ await mkdir(dirname(nativeOutputPath), { recursive: true })
524
+ } else if (needsNativeSuffix) {
525
+ // Base file building both targets - create separate .native.tsx
526
+ nativeOutputPath = sourcePath.replace(/\.(tsx|jsx)$/, '.native.$1')
431
527
  }
432
- })()
433
- )
434
- }
435
528
 
436
- await Promise.all(promises)
529
+ if (nativeOut.output.code) {
530
+ if (nativeOut.output.changed) {
531
+ stats.filesProcessed++
532
+ stats.flattened += nativeOut.plan.stats.flattened
533
+ }
437
534
 
438
- if (isDryRun) {
439
- console.info(
440
- `\n${stats.filesProcessed} files | ${stats.found} found | ${stats.optimized} optimized | ${stats.flattened} flattened | ${stats.styled} styled\n`
441
- )
442
- }
535
+ // Track original if overwriting existing file (skip if using output dir or outputAround)
536
+ if (
537
+ !outputDir &&
538
+ !outputAround &&
539
+ (nativeOutputPath === sourcePath || filePlatforms.length === 1)
540
+ ) {
541
+ await trackFile(nativeOutputPath)
542
+ }
543
+ await writeFile(nativeOutputPath, nativeOut.output.code, 'utf-8')
544
+ if (!outputDir && !outputAround) {
545
+ await recordMtime(nativeOutputPath)
546
+ }
443
547
 
444
- // Verify expected optimizations if specified
445
- if (options.expectOptimizations !== undefined) {
446
- const totalOptimizations = stats.optimized + stats.flattened
447
- if (totalOptimizations < options.expectOptimizations) {
448
- console.error(
449
- `\nExpected at least ${options.expectOptimizations} optimizations but only got ${totalOptimizations}`
548
+ // If creating new .native.tsx, track for deletion (skip if using output dir or outputAround)
549
+ if (
550
+ !outputDir &&
551
+ !outputAround &&
552
+ nativeOutputPath !== sourcePath &&
553
+ filePlatforms.length > 1
554
+ ) {
555
+ trackedFiles.push({
556
+ path: nativeOutputPath,
557
+ hardlinkPath: '', // Empty = delete on restore
558
+ mtimeAfterWrite: (await stat(nativeOutputPath)).mtimeMs,
559
+ })
560
+ }
561
+
562
+ if (outputAround) {
563
+ console.info(` → ${nativeOutputPath}`)
564
+ }
565
+ }
566
+ }
567
+ }
568
+
569
+ // process.env.TAMAGUI_TARGET is process-global, and many tamagui modules read it
570
+ // at module-evaluation time (font family/size tables, isWeb, CSS-variable
571
+ // emission). Compiling files concurrently while flipping it per file let one
572
+ // file's target leak into another's in-flight compile, so the same source could
573
+ // emit web classes built from native config. Finish every file for one target
574
+ // before switching targets.
575
+ for (const target of targets) {
576
+ process.env.TAMAGUI_TARGET = target
577
+ const targetFiles = [...fileToTargets].filter(([, filePlatforms]) =>
578
+ filePlatforms.includes(target)
579
+ )
580
+ const targetPromises = targetFiles.map(([sourcePath, filePlatforms]) =>
581
+ target === 'web'
582
+ ? buildWebFile(sourcePath, sources.get(sourcePath)!)
583
+ : buildNativeFile(sourcePath, filePlatforms, sources.get(sourcePath)!)
450
584
  )
451
- console.error(`Stats: ${JSON.stringify(stats, null, 2)}`)
452
- process.exit(1)
585
+ promises.push(...targetPromises)
586
+ await Promise.all(targetPromises)
453
587
  }
454
- console.info(
455
- `\n✓ Met optimization target: ${totalOptimizations} >= ${options.expectOptimizations}`
456
- )
457
- }
458
588
 
459
- // If a command was provided, run it and then restore files
460
- if (options.runCommand && options.runCommand.length > 0) {
461
- const command = options.runCommand.join(' ')
462
- console.info(`\nRunning: ${command}\n`)
589
+ if (isDryRun) {
590
+ console.info(
591
+ `\n${stats.filesProcessed} files | ${stats.found} found | ${stats.optimized} optimized | ${stats.flattened} flattened | ${stats.styled} styled | ${stats.bailed} bailed\n`
592
+ )
593
+ }
463
594
 
464
- try {
465
- execSync(command, { stdio: 'inherit' })
466
- } catch (err: any) {
467
- console.error(`\nCommand failed with exit code ${err.status || 1}`)
468
- process.exitCode = err.status || 1
469
- } finally {
470
- // Always restore files
471
- await restoreFiles(trackedFiles, restoreDir)
595
+ // Verify expected optimizations if specified
596
+ if (options.expectOptimizations !== undefined) {
597
+ const totalOptimizations = stats.optimized + stats.flattened
598
+ if (totalOptimizations < options.expectOptimizations) {
599
+ console.error(
600
+ `\nExpected at least ${options.expectOptimizations} optimizations but only got ${totalOptimizations}`
601
+ )
602
+ console.error(`Stats: ${JSON.stringify(stats, null, 2)}`)
603
+ await restoreTrackedFiles(true)
604
+ throw new Error(
605
+ `Expected at least ${options.expectOptimizations} optimizations but only got ${totalOptimizations}`
606
+ )
607
+ }
608
+ console.info(
609
+ `\n✓ Met optimization target: ${totalOptimizations} >= ${options.expectOptimizations}`
610
+ )
472
611
  }
612
+
613
+ // If a command was provided, run it and then restore files
614
+ if (options.runCommand && options.runCommand.length > 0) {
615
+ let commandFailed = true
616
+
617
+ try {
618
+ await runCommand(options.runCommand, () => restoreTrackedFiles(true))
619
+ commandFailed = false
620
+ } catch (err: any) {
621
+ console.error(`\nCommand failed with exit code ${err.status || 1}`)
622
+ throw err
623
+ } finally {
624
+ // Always restore files
625
+ await restoreTrackedFiles(commandFailed)
626
+ }
627
+ }
628
+
629
+ return { stats, trackedFiles }
630
+ } catch (error) {
631
+ await Promise.allSettled(promises)
632
+ await restoreTrackedFiles(true)
633
+ throw error
473
634
  }
635
+ }
474
636
 
475
- return { stats, trackedFiles }
637
+ async function runCommand(
638
+ command: string[],
639
+ restore: () => Promise<void>
640
+ ): Promise<void> {
641
+ await new Promise<void>((resolveCommand, rejectCommand) => {
642
+ const child = spawn(command[0], command.slice(1), { stdio: 'inherit' })
643
+ let settled = false
644
+ let listenersRestored = false
645
+ const interruptListeners = process.listeners('SIGINT')
646
+ const terminateListeners = process.listeners('SIGTERM')
647
+ process.removeAllListeners('SIGINT')
648
+ process.removeAllListeners('SIGTERM')
649
+
650
+ const cleanup = () => {
651
+ process.off('SIGINT', onInterrupt)
652
+ process.off('SIGTERM', onTerminate)
653
+ if (listenersRestored) return
654
+ listenersRestored = true
655
+ for (const listener of interruptListeners) process.on('SIGINT', listener)
656
+ for (const listener of terminateListeners) process.on('SIGTERM', listener)
657
+ }
658
+ const finishSignal = (signal: NodeJS.Signals) => {
659
+ if (settled) return
660
+ settled = true
661
+ void (async () => {
662
+ const childExited =
663
+ child.exitCode === null && child.signalCode === null
664
+ ? new Promise<void>((resolveExit) => child.once('exit', () => resolveExit()))
665
+ : Promise.resolve()
666
+ child.kill(signal)
667
+ await childExited
668
+ try {
669
+ await restore()
670
+ } finally {
671
+ cleanup()
672
+ process.exit(signal === 'SIGINT' ? 130 : 143)
673
+ }
674
+ })()
675
+ }
676
+ const onInterrupt = () => finishSignal('SIGINT')
677
+ const onTerminate = () => finishSignal('SIGTERM')
678
+ process.once('SIGINT', onInterrupt)
679
+ process.once('SIGTERM', onTerminate)
680
+ console.info(`\nRunning: ${command.join(' ')}\n`)
681
+
682
+ child.once('error', (error) => {
683
+ if (settled) return
684
+ settled = true
685
+ cleanup()
686
+ rejectCommand(error)
687
+ })
688
+ child.once('exit', (code, signal) => {
689
+ if (settled) return
690
+ settled = true
691
+ cleanup()
692
+ if (code === 0) {
693
+ resolveCommand()
694
+ return
695
+ }
696
+ const error = new Error(
697
+ signal ? `Command terminated by ${signal}` : `Command exited with code ${code}`
698
+ ) as Error & { status?: number }
699
+ error.status = code ?? 1
700
+ rejectCommand(error)
701
+ })
702
+ })
476
703
  }
477
704
 
478
705
  async function restoreFiles(
479
706
  trackedFiles: TrackedFile[],
480
- restoreDir: string | null
707
+ restoreDir: string | null,
708
+ force = false
481
709
  ): Promise<void> {
482
- if (!restoreDir || trackedFiles.length === 0) return
710
+ if (!restoreDir) return
711
+ if (trackedFiles.length === 0) {
712
+ await rm(restoreDir, { recursive: true, force: true })
713
+ return
714
+ }
483
715
 
484
716
  console.info(`\nRestoring ${trackedFiles.length} files...`)
485
717
  let restored = 0
@@ -491,7 +723,7 @@ async function restoreFiles(
491
723
  const currentStat = await stat(tracked.path).catch(() => null)
492
724
 
493
725
  // Check if file was modified during command execution
494
- if (currentStat && currentStat.mtimeMs !== tracked.mtimeAfterWrite) {
726
+ if (!force && currentStat && currentStat.mtimeMs !== tracked.mtimeAfterWrite) {
495
727
  console.warn(` Skipping ${tracked.path} - modified during build`)
496
728
  skipped++
497
729
  continue