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