@tamagui/vite-plugin 2.7.6 → 3.0.0-beta.637.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/plugin.ts CHANGED
@@ -1,56 +1,367 @@
1
- import type { TamaguiOptions, ExtractedResponse } from '@tamagui/static-worker'
2
- import * as Static from '@tamagui/static-worker'
3
- import { getPragmaOptions } from '@tamagui/static-worker'
1
+ import Static from '@tamagui/static'
2
+ import type { TamaguiOptions, ZeroGraphReceipt } from '@tamagui/static'
4
3
  import { createHash } from 'node:crypto'
5
- import { readdirSync } from 'node:fs'
4
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
5
+ import { gzipSync } from 'node:zlib'
6
+ import { readFile } from 'node:fs/promises'
6
7
  import { createRequire } from 'node:module'
7
8
  import path from 'node:path'
8
9
  import { fileURLToPath } from 'node:url'
9
- import type { Plugin, PluginOption, ResolvedConfig, ViteDevServer } from 'vite'
10
+ import {
11
+ createIdResolver,
12
+ createRunnableDevEnvironment,
13
+ defaultClientConditions,
14
+ defaultClientMainFields,
15
+ isRunnableDevEnvironment,
16
+ resolveConfig,
17
+ } from 'vite'
18
+ import type {
19
+ EnvironmentOptions,
20
+ EnvironmentModuleNode,
21
+ Plugin,
22
+ PluginOption,
23
+ ResolvedConfig,
24
+ ViteDevServer,
25
+ } from 'vite'
10
26
  import type { Environment } from 'vite'
27
+ import type { ViteTamaguiLoader } from './loadTamagui'
28
+ import { createViteTamaguiLoader, TAMAGUI_EVALUATION_ENVIRONMENT } from './loadTamagui'
29
+ import {
30
+ createCompilerStatsReport,
31
+ formatCompilerStatsReport,
32
+ type CompilerModuleReport,
33
+ } from './compilerStats'
11
34
  import {
12
- loadTamaguiBuildConfig,
13
- getLoadPromise,
14
- getTamaguiOptions,
15
- ensureFullConfigLoaded,
16
- } from './loadTamagui'
35
+ assertZeroGraph,
36
+ buildIsland,
37
+ createZeroRuntimeController,
38
+ finalizeZeroCSS,
39
+ zeroModuleKey,
40
+ ZERO_CSS_FILENAME,
41
+ ZERO_ISLAND_DIRNAME,
42
+ type ZeroIslandBuildContext,
43
+ type ZeroRuntimeController,
44
+ } from './zeroRuntime'
45
+
46
+ const environmentSpecificTransformPluginNames = new Set([
47
+ 'one:compiler',
48
+ 'one:compiler-css-to-js',
49
+ ])
50
+
51
+ const oneTsconfigPathsPluginName = 'one:tsconfig-paths'
52
+ const bareTamaguiPackage = /^@tamagui\/[^/?#]+(?:[/?#]|$)/
53
+ const inlineEvaluationTamaguiPackage = /^@tamagui\/(?:config|core|slider|web)(?:[/?#]|$)/
54
+ const externalizablePackageExtensions = new Set(['', '.js', '.mjs', '.cjs'])
55
+ type EvaluationResolveIdHandler = (this: any, source: string, ...args: any[]) => any
56
+ type EvaluationBarePackageResolver = (
57
+ environment: Environment,
58
+ source: string,
59
+ importer?: string
60
+ ) =>
61
+ | Promise<string | { id: string; external: true } | undefined>
62
+ | string
63
+ | { id: string; external: true }
64
+ | undefined
65
+
66
+ function createEvaluationResolveId(
67
+ plugin: Plugin,
68
+ resolveBarePackage?: EvaluationBarePackageResolver
69
+ ): Plugin['resolveId'] {
70
+ const resolveId = plugin.resolveId
71
+ if (plugin.name !== oneTsconfigPathsPluginName || !resolveId) {
72
+ return resolveId
73
+ }
17
74
 
18
- // handle ESM/CJS duality for plugin dependencies - resolve from plugin's location, not user's project
19
- const _pluginRequire = createRequire(
20
- typeof __filename === 'string' ? __filename : fileURLToPath(import.meta.url)
21
- )
22
- const resolve = (name: string) => _pluginRequire.resolve(name)
23
- const normalizePath = (value: string) => value.replace(/\\/g, '/')
75
+ const handler = (
76
+ typeof resolveId === 'object' ? resolveId.handler : resolveId
77
+ ) as EvaluationResolveIdHandler
78
+ const evaluationHandler = function (this: any, source: string, ...args: any[]) {
79
+ // One's TS-path resolver can map workspace package imports to Metro's CJS
80
+ // directory fallbacks before Vite can apply the package exports map. Keep
81
+ // user TS aliases in this resolver, but let Tamagui packages use Vite's
82
+ // normal package resolution and externalization policy.
83
+ if (bareTamaguiPackage.test(source)) {
84
+ const importer = typeof args[0] === 'string' ? args[0] : undefined
85
+ return resolveBarePackage?.(this.environment, source, importer)
86
+ }
87
+ return Reflect.apply(handler, this, [source, ...args])
88
+ }
89
+
90
+ return typeof resolveId === 'object'
91
+ ? { ...resolveId, handler: evaluationHandler }
92
+ : evaluationHandler
93
+ }
94
+
95
+ function createEvaluationPluginFacade(
96
+ plugin: Plugin,
97
+ resolveBarePackage?: EvaluationBarePackageResolver
98
+ ): Plugin {
99
+ return {
100
+ name: plugin.name,
101
+ enforce: plugin.enforce,
102
+ resolveId: createEvaluationResolveId(plugin, resolveBarePackage),
103
+ load: plugin.load,
104
+ transform: environmentSpecificTransformPluginNames.has(plugin.name)
105
+ ? undefined
106
+ : plugin.transform,
107
+ }
108
+ }
24
109
 
25
- // shared cache across all plugin instances/environments via globalThis
26
- type CacheEntry = {
27
- js: string
28
- map: any
29
- cssImport: string | null
110
+ const tamaguiEvaluationPluginNames = new Set([
111
+ 'tamagui',
112
+ 'tamagui-extract',
113
+ 'tamagui-rnw-lite',
114
+ ])
115
+
116
+ function isEvaluationUserPlugin(plugin: Plugin) {
117
+ return (
118
+ !!(plugin.resolveId || plugin.load || plugin.transform) &&
119
+ plugin.name !== 'alias' &&
120
+ !plugin.name.startsWith('native:') &&
121
+ !plugin.name.startsWith('vite:') &&
122
+ !plugin.name.startsWith('builtin:vite-') &&
123
+ !tamaguiEvaluationPluginNames.has(plugin.name)
124
+ )
30
125
  }
31
126
 
32
- const CACHE_KEY = '__tamagui_vite_cache__'
33
- const CACHE_SIZE_KEY = '__tamagui_vite_cache_size__'
34
- const PENDING_KEY = '__tamagui_vite_pending__'
127
+ function isEvaluationCorePlugin(plugin: Plugin) {
128
+ return (
129
+ plugin.name === 'alias' ||
130
+ plugin.name.startsWith('vite:') ||
131
+ plugin.name.startsWith('builtin:vite-')
132
+ )
133
+ }
35
134
 
36
- function getSharedCache(): Record<string, CacheEntry> {
37
- if (!(globalThis as any)[CACHE_KEY]) {
38
- ;(globalThis as any)[CACHE_KEY] = {}
135
+ function isConfiguredEvaluationPackage(source: string, packages: Set<string>) {
136
+ const cleanSource = source.split(/[?#]/, 1)[0]
137
+ return [...packages].some(
138
+ (packageName) =>
139
+ cleanSource === packageName || cleanSource.startsWith(`${packageName}/`)
140
+ )
141
+ }
142
+
143
+ function getEvaluationPackageName(source: string | undefined) {
144
+ if (!source) return
145
+ const cleanSource = source.split(/[?#]/, 1)[0]
146
+ if (
147
+ !cleanSource ||
148
+ cleanSource.startsWith('.') ||
149
+ cleanSource.startsWith('#') ||
150
+ cleanSource.startsWith('\0') ||
151
+ path.isAbsolute(cleanSource)
152
+ ) {
153
+ return
154
+ }
155
+ if (cleanSource.startsWith('@')) {
156
+ const [scope, name] = cleanSource.split('/')
157
+ return scope && name ? `${scope}/${name}` : undefined
39
158
  }
40
- return (globalThis as any)[CACHE_KEY]
159
+ const [name] = cleanSource.split('/')
160
+ return name && !path.extname(name) ? name : undefined
41
161
  }
42
162
 
43
- function getSharedCacheSize(): number {
44
- return (globalThis as any)[CACHE_SIZE_KEY] || 0
163
+ function getInstalledTamaguiPackages(
164
+ root: string,
165
+ configuredEvaluationPackages: Set<string>
166
+ ) {
167
+ const packageRequire = createRequire(path.join(root, 'package.json'))
168
+ const packages = new Set<string>()
169
+
170
+ for (const modulePath of packageRequire.resolve.paths('@tamagui/core') || []) {
171
+ const scopePath = path.join(modulePath, '@tamagui')
172
+ if (!existsSync(scopePath)) continue
173
+ for (const entry of readdirSync(scopePath, { withFileTypes: true })) {
174
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
175
+ const packageName = `@tamagui/${entry.name}`
176
+ if (
177
+ !inlineEvaluationTamaguiPackage.test(packageName) &&
178
+ !configuredEvaluationPackages.has(packageName)
179
+ ) {
180
+ packages.add(packageName)
181
+ }
182
+ }
183
+ }
184
+
185
+ return packages
45
186
  }
46
187
 
47
- function setSharedCacheSize(size: number) {
48
- ;(globalThis as any)[CACHE_SIZE_KEY] = size
188
+ function getEvaluationResolve(
189
+ resolve: ResolvedConfig['environments'][string]['resolve'],
190
+ root: string,
191
+ disableTsconfigPaths: boolean,
192
+ configuredEvaluationPackages: Set<string>
193
+ ) {
194
+ return {
195
+ ...resolve,
196
+ external:
197
+ resolve.external === true
198
+ ? (true as const)
199
+ : [
200
+ ...new Set([
201
+ ...(resolve.external || []).filter(
202
+ (packageName) =>
203
+ !isConfiguredEvaluationPackage(
204
+ packageName,
205
+ configuredEvaluationPackages
206
+ )
207
+ ),
208
+ ...getInstalledTamaguiPackages(root, configuredEvaluationPackages),
209
+ ]),
210
+ ],
211
+ ...(disableTsconfigPaths && { tsconfigPaths: false }),
212
+ }
49
213
  }
50
214
 
51
- function clearSharedCache() {
52
- ;(globalThis as any)[CACHE_KEY] = {}
53
- ;(globalThis as any)[CACHE_SIZE_KEY] = 0
215
+ function isConfiguredExternalPackage(
216
+ source: string,
217
+ external: string[] | true | undefined
218
+ ) {
219
+ if (external === true) return true
220
+ const cleanSource = source.split(/[?#]/, 1)[0]
221
+ return external?.some(
222
+ (packageName) =>
223
+ cleanSource === packageName || cleanSource.startsWith(`${packageName}/`)
224
+ )
225
+ }
226
+
227
+ function createServeEvaluationConfig(
228
+ config: ResolvedConfig,
229
+ configuredEvaluationPackages: Set<string>
230
+ ): ResolvedConfig {
231
+ const environment = config.environments[TAMAGUI_EVALUATION_ENVIRONMENT]
232
+ let packageResolver: ReturnType<typeof createIdResolver> | undefined
233
+ const resolveBarePackage: EvaluationBarePackageResolver = async (
234
+ evaluationEnvironment,
235
+ source,
236
+ importer
237
+ ) => {
238
+ const resolved = await packageResolver?.(evaluationEnvironment, source, importer)
239
+ if (!resolved) return
240
+ const cleanResolved = resolved.split(/[?#]/, 1)[0]
241
+ if (
242
+ !inlineEvaluationTamaguiPackage.test(source) &&
243
+ !isConfiguredEvaluationPackage(source, configuredEvaluationPackages) &&
244
+ isConfiguredExternalPackage(source, evaluationEnvironment.config.resolve.external)
245
+ ) {
246
+ return { id: source, external: true }
247
+ }
248
+ if (
249
+ inlineEvaluationTamaguiPackage.test(source) ||
250
+ isConfiguredEvaluationPackage(source, configuredEvaluationPackages) ||
251
+ !normalizePath(cleanResolved).includes('/node_modules/') ||
252
+ !externalizablePackageExtensions.has(path.extname(cleanResolved))
253
+ ) {
254
+ return resolved
255
+ }
256
+ return { id: source, external: true }
257
+ }
258
+ const plugins = environment.plugins.flatMap((plugin) => {
259
+ if (isEvaluationCorePlugin(plugin)) {
260
+ return [plugin]
261
+ }
262
+ if (isEvaluationUserPlugin(plugin)) {
263
+ return [createEvaluationPluginFacade(plugin, resolveBarePackage)]
264
+ }
265
+ return []
266
+ })
267
+ const resolve = getEvaluationResolve(
268
+ environment.resolve,
269
+ config.root,
270
+ plugins.some((plugin) => plugin.name === oneTsconfigPathsPluginName),
271
+ configuredEvaluationPackages
272
+ )
273
+
274
+ const evaluationConfig: ResolvedConfig = {
275
+ ...config,
276
+ environments: {
277
+ ...config.environments,
278
+ [TAMAGUI_EVALUATION_ENVIRONMENT]: {
279
+ ...environment,
280
+ plugins,
281
+ resolve,
282
+ },
283
+ },
284
+ }
285
+ packageResolver = createIdResolver(evaluationConfig)
286
+ return evaluationConfig
287
+ }
288
+
289
+ async function createOwnedEvaluationConfig(
290
+ config: ResolvedConfig,
291
+ configuredEvaluationPackages: Set<string>
292
+ ) {
293
+ const environment = config.environments[TAMAGUI_EVALUATION_ENVIRONMENT]
294
+ const plugins = environment.plugins
295
+ .filter(isEvaluationUserPlugin)
296
+ .map((plugin) => createEvaluationPluginFacade(plugin))
297
+ const resolve = getEvaluationResolve(
298
+ environment.resolve,
299
+ config.root,
300
+ plugins.some((plugin) => plugin.name === oneTsconfigPathsPluginName),
301
+ configuredEvaluationPackages
302
+ )
303
+ const { createEnvironment: _createEnvironment, ...dev } = environment.dev
304
+
305
+ // ModuleRunner needs Vite's serve-time core pipeline (especially import
306
+ // analysis), but user plugin selection must remain the already-resolved
307
+ // pipeline for the outer command. The facades retain only evaluation hooks,
308
+ // so resolving this owned config cannot replay user configuration or outer
309
+ // lifecycles.
310
+ return resolveConfig(
311
+ {
312
+ configFile: false,
313
+ root: config.root,
314
+ mode: config.mode,
315
+ logLevel: config.logLevel,
316
+ plugins,
317
+ define: environment.define,
318
+ resolve,
319
+ environments: {
320
+ [TAMAGUI_EVALUATION_ENVIRONMENT]: {
321
+ consumer: environment.consumer,
322
+ keepProcessEnv: environment.keepProcessEnv,
323
+ define: environment.define,
324
+ resolve,
325
+ optimizeDeps: environment.optimizeDeps,
326
+ dev: {
327
+ ...dev,
328
+ moduleRunnerTransform: true,
329
+ },
330
+ },
331
+ },
332
+ },
333
+ 'serve',
334
+ config.mode
335
+ )
336
+ }
337
+
338
+ // handle ESM/CJS duality for plugin dependencies - resolve from plugin's location, not user's project
339
+ const _pluginRequire = createRequire(
340
+ typeof __filename === 'string' ? __filename : fileURLToPath(import.meta.url)
341
+ )
342
+ const resolve = (name: string) => _pluginRequire.resolve(name)
343
+ const normalizePath = (value: string) => value.replace(/\\/g, '/')
344
+
345
+ const PLUGIN_INSTANCE_KEY = '__tamagui_vite_plugin_instance__'
346
+
347
+ function reportCompilerStats(root: string, reports: Map<string, CompilerModuleReport>) {
348
+ const report = createCompilerStatsReport(root, reports)
349
+ console.info(
350
+ formatCompilerStatsReport(report, process.env.TAMAGUI_COMPILER_STATS === 'verbose')
351
+ )
352
+ if (process.env.TAMAGUI_COMPILER_STATS_FILE) {
353
+ const outputPath = path.resolve(root, process.env.TAMAGUI_COMPILER_STATS_FILE)
354
+ writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`)
355
+ console.info(
356
+ `[tamagui] compiler stats JSON: ${path.relative(process.cwd(), outputPath)}`
357
+ )
358
+ }
359
+ }
360
+
361
+ function getNextPluginInstanceId() {
362
+ const next = ((globalThis as any)[PLUGIN_INSTANCE_KEY] || 0) + 1
363
+ ;(globalThis as any)[PLUGIN_INSTANCE_KEY] = next
364
+ return next
54
365
  }
55
366
 
56
367
  // resolves package ids against the user's project root (not the plugin's
@@ -81,14 +392,6 @@ function addIfInstalled(
81
392
  }
82
393
  }
83
394
 
84
- // pending extractions map - dedupes concurrent requests for same file
85
- function getPendingExtractions(): Map<string, Promise<CacheEntry | null>> {
86
- if (!(globalThis as any)[PENDING_KEY]) {
87
- ;(globalThis as any)[PENDING_KEY] = new Map()
88
- }
89
- return (globalThis as any)[PENDING_KEY]
90
- }
91
-
92
395
  type AliasOptions = {
93
396
  /** use @tamagui/react-native-web-lite, 'without-animated' for smaller bundle */
94
397
  rnwLite?: boolean | 'without-animated'
@@ -124,8 +427,8 @@ export function tamaguiAliases(options: AliasOptions = {}): AliasEntry[] {
124
427
  : 'dist/esm/index.mjs'
125
428
  )
126
429
  )
127
- // the flat module names rnw-lite ships, used to scope the deep-path alias
128
- // below so unimplemented react-native-web exports are left alone.
430
+ // only alias deep imports that rnw-lite actually implements. unimplemented
431
+ // react-native-web exports must fall through to the full package.
129
432
  const rnwlFlatModules = readdirSync(path.join(rnwlBase, 'dist/esm'))
130
433
  .filter((file) => file.endsWith('.mjs'))
131
434
  .map((file) => file.slice(0, -'.mjs'.length))
@@ -134,15 +437,6 @@ export function tamaguiAliases(options: AliasOptions = {}): AliasEntry[] {
134
437
  {
135
438
  // map deep RNW paths like dist/exports/StyleSheet/preprocess to rnw-lite's flat structure
136
439
  // extracts the final path segment (e.g. "preprocess" or "createReactDOMStyle")
137
- //
138
- // only match segments rnw-lite actually ships. it implements part of
139
- // react-native-web's export surface, not all of it, and there is no
140
- // flat StyleSheet.mjs. expo sdk 56 added
141
- // expo/src/launch/AppRegistry.web.tsx, which does
142
- // `require('react-native-web/dist/exports/StyleSheet')`; the unscoped
143
- // pattern rewrote that onto a file that does not exist and failed the
144
- // whole optimize. anything lite lacks now falls through to the real
145
- // package.
146
440
  find: new RegExp(
147
441
  `^react-native(?:-web)?\\/dist\\/(?:exports|modules)\\/(?:.*\\/)?(${rnwlFlatModules.join('|')})$`
148
442
  ),
@@ -170,18 +464,269 @@ export function tamaguiAliases(options: AliasOptions = {}): AliasEntry[] {
170
464
  return aliases
171
465
  }
172
466
 
173
- export function tamaguiPlugin({
467
+ type VxrnNativePluginContext = {
468
+ root: string
469
+ platform: 'ios' | 'android'
470
+ dev: boolean
471
+ }
472
+
473
+ function createTamaguiNativePlugin(
474
+ tamaguiOptionsIn: TamaguiOptions,
475
+ nativeContext?: VxrnNativePluginContext
476
+ ): Plugin {
477
+ let compilerFrontend = new Static.CompilerFrontend()
478
+ const projectDependencies = new Set<string>()
479
+ let root = nativeContext?.root || process.cwd()
480
+ let projectPromise: Promise<Static.CompilerProject | null> | null = null
481
+ let rebuildProject = false
482
+ let generation = 0
483
+
484
+ const loadProject = async (resolveModule: (specifier: string) => Promise<string>) => {
485
+ if (projectPromise) return projectPromise
486
+ const shouldRebuild = rebuildProject
487
+ rebuildProject = false
488
+ const pending = (async () => {
489
+ projectDependencies.clear()
490
+ const loadedOptions = await Static.loadTamaguiBuildConfigAsync({
491
+ ...tamaguiOptionsIn,
492
+ root,
493
+ platform: 'native',
494
+ outputCSS: undefined,
495
+ })
496
+ const options = { ...loadedOptions, root, outputCSS: undefined }
497
+ for (const dependency of Static.getTamaguiBuildConfigDependencies(loadedOptions)) {
498
+ projectDependencies.add(normalizePath(dependency))
499
+ }
500
+ if (options.disable || options.disableExtraction) return null
501
+ const project = await Static.loadCompilerProject({
502
+ root,
503
+ target: 'native',
504
+ options,
505
+ rebuild: shouldRebuild,
506
+ generation: `vite-native:${generation + 1}`,
507
+ missingProjectMessage:
508
+ 'Unable to load the Tamagui project for Vite native compilation',
509
+ async resolveComponents(moduleNames) {
510
+ return Promise.all(
511
+ moduleNames.map(async (moduleName) => {
512
+ const id = await resolveModule(moduleName)
513
+ projectDependencies.add(normalizePath(id.split(/[?#]/, 1)[0]))
514
+ return { moduleName, id }
515
+ })
516
+ )
517
+ },
518
+ })
519
+ for (const dependency of project.projectInfo.dependencies ?? []) {
520
+ projectDependencies.add(normalizePath(dependency.split(/[?#]/, 1)[0]))
521
+ }
522
+ const configPath = options.config || 'tamagui.config.ts'
523
+ projectDependencies.add(
524
+ normalizePath(
525
+ path.isAbsolute(configPath) ? configPath : path.resolve(root, configPath)
526
+ )
527
+ )
528
+ const buildFile = options.buildFile || 'tamagui.build.ts'
529
+ projectDependencies.add(
530
+ normalizePath(
531
+ path.isAbsolute(buildFile) ? buildFile : path.resolve(root, buildFile)
532
+ )
533
+ )
534
+ if (options.themeBuilder?.input) {
535
+ projectDependencies.add(
536
+ normalizePath(
537
+ path.isAbsolute(options.themeBuilder.input)
538
+ ? options.themeBuilder.input
539
+ : path.resolve(root, options.themeBuilder.input)
540
+ )
541
+ )
542
+ }
543
+ generation++
544
+ return project
545
+ })()
546
+ const guarded = pending.catch((error) => {
547
+ if (projectPromise === guarded) projectPromise = null
548
+ rebuildProject = true
549
+ throw error
550
+ })
551
+ projectPromise = guarded
552
+ return projectPromise
553
+ }
554
+
555
+ return {
556
+ name: 'tamagui-native-compiler',
557
+ enforce: 'post',
558
+ configResolved(config) {
559
+ root = config.root
560
+ },
561
+ watchChange(id) {
562
+ if (projectDependencies.has(normalizePath(id.split(/[?#]/, 1)[0]))) {
563
+ rebuildProject = true
564
+ projectPromise = null
565
+ compilerFrontend = new Static.CompilerFrontend()
566
+ }
567
+ },
568
+ transform: {
569
+ order: 'pre',
570
+ async handler(code, id) {
571
+ const environmentName = nativeContext?.platform || this.environment?.name
572
+ if (environmentName !== 'ios' && environmentName !== 'android') return
573
+ const [validId] = id.split('?')
574
+ if (
575
+ !validId ||
576
+ !/\.[jt]sx$/.test(validId) ||
577
+ normalizePath(validId).split('/').includes('node_modules')
578
+ ) {
579
+ return
580
+ }
581
+ const { shouldDisable } = await Static.getPragmaOptions({
582
+ source: code,
583
+ path: validId,
584
+ })
585
+ if (shouldDisable) return
586
+
587
+ const resolve = async (specifier: string, importer: string) => {
588
+ const resolution = await this.resolve(specifier, importer, { skipSelf: true })
589
+ return resolution
590
+ ? { id: resolution.id, external: resolution.external === true }
591
+ : null
592
+ }
593
+ const project = await loadProject(async (specifier) => {
594
+ const resolution = await resolve(
595
+ specifier,
596
+ path.join(root, '__tamagui_native.tsx')
597
+ )
598
+ if (!resolution) {
599
+ throw new Error(`Unable to resolve native compiler component ${specifier}`)
600
+ }
601
+ return resolution.id
602
+ })
603
+ if (!project) return
604
+ for (const dependency of projectDependencies) this.addWatchFile(dependency)
605
+
606
+ const result = await compilerFrontend.compile({
607
+ id: validId,
608
+ source: code,
609
+ root,
610
+ target: 'native',
611
+ project,
612
+ resolve,
613
+ load: async (dependencyId) => {
614
+ const cleanDependencyId = dependencyId.split(/[?#]/, 1)[0]
615
+ if (!path.isAbsolute(cleanDependencyId)) return null
616
+ try {
617
+ return await readFile(cleanDependencyId, 'utf8')
618
+ } catch {
619
+ return null
620
+ }
621
+ },
622
+ })
623
+ for (const dependency of result.plan.dependencies) {
624
+ if (path.isAbsolute(dependency)) this.addWatchFile(dependency)
625
+ }
626
+ if (result.plan.css) {
627
+ throw new Error(
628
+ `Native Tamagui compilation produced unexpected CSS for ${validId}`
629
+ )
630
+ }
631
+ return result.output.changed
632
+ ? { code: result.output.code, map: result.output.map as any }
633
+ : undefined
634
+ },
635
+ },
636
+ }
637
+ }
638
+
639
+ export function tamaguiNativePlugin(tamaguiOptionsIn: TamaguiOptions = {}): Plugin {
640
+ const plugin = createTamaguiNativePlugin(tamaguiOptionsIn)
641
+ const api =
642
+ plugin.api && typeof plugin.api === 'object'
643
+ ? (plugin.api as Record<string, unknown>)
644
+ : {}
645
+
646
+ return {
647
+ ...plugin,
648
+ api: {
649
+ ...api,
650
+ vxrnNative: (context: VxrnNativePluginContext) =>
651
+ createTamaguiNativePlugin(tamaguiOptionsIn, context),
652
+ },
653
+ }
654
+ }
655
+
656
+ export type TamaguiVitePluginOptions = TamaguiOptions & {
657
+ disableResolveConfig?: boolean
658
+ }
659
+
660
+ export type TamaguiInternalPluginOptions = TamaguiVitePluginOptions & {
661
+ /**
662
+ * Wraps compiler-extracted Tamagui CSS before it is served.
663
+ * `@tamagui/tailwind/vite` uses it to put those rules in `@layer tamagui`, which is
664
+ * what orders them against official Tailwind's `theme`/`utilities` layers.
665
+ */
666
+ wrapExtractedCSS?: (css: string) => string
667
+ /**
668
+ * Set by the zero-runtime controller when this invocation is an island child
669
+ * build. The island keeps the full runtime and contributes its compiler atomic
670
+ * CSS to the parent's single artifact instead of injecting its own.
671
+ */
672
+ zeroIslandBuild?: ZeroIslandBuildContext
673
+ }
674
+
675
+ /**
676
+ * The base Tamagui Vite plugins plus the one config loader they evaluate through.
677
+ *
678
+ * `@tamagui/tailwind/vite` wraps this: it reuses the returned loader for its own
679
+ * scanner plugin, so the Tamagui config is evaluated exactly once for both.
680
+ */
681
+ export function createTamaguiPlugins({
174
682
  disableResolveConfig,
683
+ wrapExtractedCSS = (css) => css,
684
+ zeroIslandBuild,
175
685
  ...tamaguiOptionsIn
176
- }: TamaguiOptions & {
177
- disableResolveConfig?: boolean
178
- } = {}): PluginOption {
686
+ }: TamaguiInternalPluginOptions = {}): {
687
+ plugins: PluginOption[]
688
+ loader: ViteTamaguiLoader
689
+ } {
179
690
  // extraction ON by default, set disableExtraction: true to opt out
180
691
  let shouldExtract = !tamaguiOptionsIn.disableExtraction
181
- let watcher: Promise<{ dispose: () => void } | void | undefined> | undefined
182
692
 
183
693
  // temporary vxrn native env bridge
184
694
  const enableNativeEnv = !!globalThis.__vxrnEnableNativeEnv
695
+ const tamaguiLoader = createViteTamaguiLoader(tamaguiOptionsIn)
696
+ const compilerFrontend = new Static.CompilerFrontend()
697
+ const pluginInstanceId = getNextPluginInstanceId()
698
+ const configuredEvaluationPackages = new Set<string>()
699
+ let buildEnvironmentPromise: Promise<void> | null = null
700
+ let buildCleanupPromise: Promise<void> | null = null
701
+ const activeBuildEnvironments = new Set<Environment>()
702
+ const compilerReports =
703
+ process.env.TAMAGUI_COMPILER_STATS || process.env.TAMAGUI_COMPILER_STATS_FILE
704
+ ? new Map<string, CompilerModuleReport>()
705
+ : null
706
+
707
+ const releaseBuildEnvironment = async (environment: Environment) => {
708
+ if (!activeBuildEnvironments.delete(environment) || activeBuildEnvironments.size) {
709
+ return
710
+ }
711
+ if (compilerReports?.size) {
712
+ reportCompilerStats(config?.root ?? process.cwd(), compilerReports)
713
+ }
714
+ const currentCleanup = Promise.resolve().then(async () => {
715
+ try {
716
+ await tamaguiLoader.cleanup()
717
+ } finally {
718
+ buildEnvironmentPromise = null
719
+ }
720
+ })
721
+ buildCleanupPromise = currentCleanup
722
+ try {
723
+ await currentCleanup
724
+ } finally {
725
+ if (buildCleanupPromise === currentCleanup) {
726
+ buildCleanupPromise = null
727
+ }
728
+ }
729
+ }
185
730
 
186
731
  const extensions = [
187
732
  `.web.mjs`,
@@ -198,14 +743,51 @@ export function tamaguiPlugin({
198
743
  '.json',
199
744
  ]
200
745
 
746
+ const getEvaluationEnvironmentOptions = (): EnvironmentOptions => ({
747
+ consumer: 'server',
748
+ keepProcessEnv: true,
749
+ define: {
750
+ 'process.env.IS_STATIC': JSON.stringify('is_static'),
751
+ 'process.env.TAMAGUI_IS_CLIENT': JSON.stringify(false),
752
+ 'process.env.TAMAGUI_IS_SERVER': JSON.stringify(true),
753
+ 'process.env.TAMAGUI_TARGET': JSON.stringify('web'),
754
+ 'process.env.TAMAGUI_ENVIRONMENT': JSON.stringify(TAMAGUI_EVALUATION_ENVIRONMENT),
755
+ // Config evaluation must retain createTamagui and CSS generation even when
756
+ // the client graph is zero or the client claims the artifact. Inheriting
757
+ // either literal from the outer build empties the artifact it generates.
758
+ 'process.env.TAMAGUI_RUNTIME': JSON.stringify('full'),
759
+ 'process.env.TAMAGUI_DID_OUTPUT_CSS': JSON.stringify(''),
760
+ // Client configs may strip theme values. Compiler evaluation and outputCSS
761
+ // must use the full config regardless of which outer Vite environment runs last.
762
+ 'process.env.VITE_ENVIRONMENT': JSON.stringify('ssr'),
763
+ 'process.env.TAMAGUI_DISABLE_SLIDER_INTERVAL': JSON.stringify('1'),
764
+ },
765
+ resolve: {
766
+ conditions: [...defaultClientConditions],
767
+ mainFields: [...defaultClientMainFields],
768
+ noExternal: [inlineEvaluationTamaguiPackage, ...configuredEvaluationPackages],
769
+ extensions,
770
+ },
771
+ dev: {
772
+ createEnvironment(name, resolved) {
773
+ const evaluationConfig = createServeEvaluationConfig(
774
+ resolved,
775
+ configuredEvaluationPackages
776
+ )
777
+ return createRunnableDevEnvironment(name, evaluationConfig)
778
+ },
779
+ moduleRunnerTransform: true,
780
+ },
781
+ })
782
+
201
783
  // start loading immediately but don't block
202
- loadTamaguiBuildConfig(tamaguiOptionsIn)
784
+ tamaguiLoader.loadTamaguiBuildConfig()
203
785
 
204
786
  // helper to await load when needed
205
787
  const ensureLoaded = async () => {
206
- const promise = getLoadPromise()
788
+ const promise = tamaguiLoader.getLoadPromise()
207
789
  if (promise) await promise
208
- const options = getTamaguiOptions()
790
+ const options = tamaguiLoader.getTamaguiOptions()
209
791
  // update shouldExtract from loaded config (tamagui.build.ts)
210
792
  if (options) {
211
793
  shouldExtract = !options.disableExtraction
@@ -216,12 +798,25 @@ export function tamaguiPlugin({
216
798
  // extract plugin state
217
799
  const getHash = (input: string) => createHash('sha1').update(input).digest('base64')
218
800
 
219
- // use shared cache across environments
220
- const memoryCache = getSharedCache()
221
-
222
801
  const cssMap = new Map<string, string>()
802
+ const transformedModuleIds = new Set<string>()
803
+ const compilerHotUpdateSignatures = new Map<string, string>()
804
+ const compilerHotReloadSignatures = new Map<string, string>()
223
805
  let config: ResolvedConfig
224
806
  let server: ViteDevServer
807
+ let zero: ZeroRuntimeController | null = null
808
+ let zeroReceipt: ZeroGraphReceipt | null = null
809
+ // closeBundle runs even when the build already failed, so a check there would
810
+ // replace the real error with a derived one
811
+ let zeroBuildFailed = false
812
+ // The compiled-global-CSS tier: an ordinary compiled build that also owns an
813
+ // `outputCSS` artifact and therefore derives TAMAGUI_DID_OUTPUT_CSS from it.
814
+ let globalCSS: Static.GlobalCSSOwnership | null = null
815
+ let globalCSSExpected: string | null = null
816
+ // How many HTML entries received the zero artifact's stylesheet link. A zero
817
+ // build with no HTML entry strips the rules and loads nothing.
818
+ let zeroHtmlEntries = 0
819
+ let zeroDevIslands: Promise<unknown> = Promise.resolve()
225
820
  const virtualExt = `.tamagui.css`
226
821
 
227
822
  const getAbsoluteVirtualFileId = (filePath: string) => {
@@ -231,6 +826,20 @@ export function tamaguiPlugin({
231
826
  return normalizePath(path.join(config.root, filePath))
232
827
  }
233
828
 
829
+ const isAppJSXSource = (filePath: string) => {
830
+ if (!/\.[jt]sx$/.test(filePath)) return false
831
+ const relative = path.relative(config.root, filePath)
832
+ return (
833
+ relative !== '' &&
834
+ relative !== '..' &&
835
+ !relative.startsWith(`..${path.sep}`) &&
836
+ !relative.split(path.sep).includes('node_modules')
837
+ )
838
+ }
839
+
840
+ const isFrameworkAnalysisRequest = (id: string) =>
841
+ id.includes('__react-router-build-client-route')
842
+
234
843
  function isNotClient(environment?: Environment) {
235
844
  return environment?.name && environment.name !== 'client'
236
845
  }
@@ -241,18 +850,21 @@ export function tamaguiPlugin({
241
850
  )
242
851
  }
243
852
 
244
- function invalidateModule(absoluteId: string) {
245
- if (!server) return
246
-
247
- const { moduleGraph } = server
248
- const modules = moduleGraph.getModulesByFile(absoluteId)
249
-
250
- if (modules) {
251
- for (const module of modules) {
252
- moduleGraph.invalidateModule(module)
253
- module.lastHMRTimestamp = module.lastInvalidationTimestamp || Date.now()
853
+ function invalidateCompilerModules() {
854
+ if (server) {
855
+ const ids = new Set([...transformedModuleIds, ...cssMap.keys()])
856
+ for (const environment of Object.values(server.environments)) {
857
+ if (environment.name === TAMAGUI_EVALUATION_ENVIRONMENT) continue
858
+ for (const id of ids) {
859
+ const modules = environment.moduleGraph.getModulesByFile(id)
860
+ if (!modules) continue
861
+ for (const module of modules) {
862
+ environment.moduleGraph.invalidateModule(module)
863
+ }
864
+ }
254
865
  }
255
866
  }
867
+ cssMap.clear()
256
868
  }
257
869
 
258
870
  const basePlugin: Plugin = {
@@ -261,15 +873,20 @@ export function tamaguiPlugin({
261
873
 
262
874
  configureServer(_server) {
263
875
  server = _server
876
+ const evaluationEnvironment = server.environments[TAMAGUI_EVALUATION_ENVIRONMENT]
877
+ if (!isRunnableDevEnvironment(evaluationEnvironment)) {
878
+ throw new Error(
879
+ `The ${TAMAGUI_EVALUATION_ENVIRONMENT} Vite environment must support ModuleRunner evaluation`
880
+ )
881
+ }
882
+ tamaguiLoader.setEnvironment(evaluationEnvironment)
264
883
  },
265
884
 
266
885
  async buildEnd() {
267
- await watcher?.then((res) => {
268
- res?.dispose()
269
- })
886
+ await releaseBuildEnvironment(this.environment)
270
887
  },
271
888
 
272
- async config(_, env) {
889
+ async config(userConfig, env) {
273
890
  const options = await ensureLoaded()
274
891
 
275
892
  if (!options) {
@@ -278,17 +895,29 @@ export function tamaguiPlugin({
278
895
  const useReactNativeWebLite =
279
896
  tamaguiOptionsIn.useReactNativeWebLite ?? options.useReactNativeWebLite
280
897
 
281
- // start watching config if enabled
282
- if (!options.disableWatchTamaguiConfig) {
283
- watcher = Static.watchTamaguiConfig({
284
- components: ['tamagui'],
285
- config: './src/tamagui.config.ts',
286
- ...options,
287
- }).catch((err) => {
288
- console.error(` [Tamagui] Error watching config: ${err}`)
289
- })
898
+ for (const source of [options.config, ...(options.components || [])]) {
899
+ const packageName = getEvaluationPackageName(source)
900
+ if (packageName) {
901
+ configuredEvaluationPackages.add(packageName)
902
+ }
290
903
  }
291
904
 
905
+ const resolvedRoot = userConfig.root ? path.resolve(userConfig.root) : process.cwd()
906
+
907
+ // An island child build is the full-runtime half of the same project, so it
908
+ // never re-enters zero mode even though it reads the same tamagui.build.ts.
909
+ zero = zeroIslandBuild
910
+ ? null
911
+ : await createZeroRuntimeController(options, resolvedRoot, userConfig.base || '/')
912
+
913
+ // The island child build's artifact is the parent's, and a dev server has
914
+ // no final graph to prove the relationship against, so both keep runtime
915
+ // CSS generation. Production is where the claim is made and gated.
916
+ globalCSS =
917
+ zeroIslandBuild || env.command !== 'build'
918
+ ? null
919
+ : Static.resolveGlobalCSSOwnership(options, resolvedRoot)
920
+
292
921
  return {
293
922
  envPrefix: ['TAMAGUI_'],
294
923
 
@@ -297,11 +926,26 @@ export function tamaguiPlugin({
297
926
  define: {
298
927
  'process.env.TAMAGUI_IS_CLIENT': JSON.stringify(true),
299
928
  'process.env.TAMAGUI_ENVIRONMENT': '"client"',
929
+ // An enforced zero client and its SSR peer both receive 'zero', so
930
+ // SSR never imports a runtime hydration removed.
931
+ ...(zero?.isEnforcing && {
932
+ 'process.env.TAMAGUI_RUNTIME': JSON.stringify('zero'),
933
+ }),
934
+ // Derived, never author-set. generateBundle proves the artifact
935
+ // exists, matches this build's config, and is in the client graph;
936
+ // a build that cannot prove it fails instead of shipping.
937
+ ...(globalCSS && {
938
+ 'process.env.TAMAGUI_DID_OUTPUT_CSS': JSON.stringify('1'),
939
+ }),
300
940
  },
301
941
  },
942
+ [TAMAGUI_EVALUATION_ENVIRONMENT]: getEvaluationEnvironmentOptions(),
302
943
  },
303
944
 
304
945
  define: {
946
+ // Config evaluation, report builds, native builds, and full-runtime
947
+ // island child builds all keep ordinary Tamagui runtime behavior.
948
+ 'process.env.TAMAGUI_RUNTIME': JSON.stringify('full'),
305
949
  // reanimated support
306
950
  _frameTimestamp: undefined,
307
951
  _WORKLET: false,
@@ -338,8 +982,8 @@ export function tamaguiPlugin({
338
982
 
339
983
  const rnwLitePlugin: Plugin = {
340
984
  name: 'tamagui-rnw-lite',
341
- // framework plugins may add their default react-native-web aliases from a
342
- // normal config hook. apply the explicit lite choice after those defaults.
985
+ // framework plugins add their default react-native-web aliases from a
986
+ // normal config hook, so apply the explicit lite choice after them.
343
987
  enforce: 'post',
344
988
 
345
989
  config() {
@@ -347,16 +991,16 @@ export function tamaguiPlugin({
347
991
  return {}
348
992
  }
349
993
 
350
- const options = getTamaguiOptions()
994
+ const options = tamaguiLoader.getTamaguiOptions()
351
995
  const useReactNativeWebLite =
352
996
  tamaguiOptionsIn.useReactNativeWebLite ?? options?.useReactNativeWebLite
353
997
  if (!useReactNativeWebLite) {
354
998
  return {}
355
999
  }
356
1000
 
357
- // the dep scanner doesn't follow transitive packages through the
358
- // react-native -> rnw-lite alias. pre-include the CJS dependencies that
359
- // would otherwise reach the browser raw or trigger a mid-load re-optimize.
1001
+ // the dep scanner does not follow transitive packages through the
1002
+ // react-native to rnw-lite alias. pre-include the CJS dependencies that
1003
+ // would otherwise reach the browser raw or trigger a mid-load optimize.
360
1004
  const include: string[] = []
361
1005
  for (const dependency of ['memoize-one', '@react-native/normalize-color']) {
362
1006
  if (isInstalled(process.cwd(), dependency)) include.push(dependency)
@@ -366,6 +1010,13 @@ export function tamaguiPlugin({
366
1010
  resolve: {
367
1011
  alias: tamaguiAliases({ rnwLite: useReactNativeWebLite }),
368
1012
  },
1013
+ ssr: {
1014
+ // Installed packages are externalized by default in SSR builds, which
1015
+ // bypasses the RNW-lite alias and executes React Native Web's CJS entry
1016
+ // directly in Node. Bundle the Tamagui/RN boundary just as Vite does
1017
+ // for linked workspace packages.
1018
+ noExternal: [/^@tamagui\//, 'tamagui', 'react-native', 'react-native-web'],
1019
+ },
369
1020
  optimizeDeps: {
370
1021
  // upstream react-native-web must not be pre-bundled when aliased to lite
371
1022
  exclude: ['react-native-web'],
@@ -388,14 +1039,16 @@ export function tamaguiPlugin({
388
1039
  userConf.optimizeDeps ||= {}
389
1040
  userConf.optimizeDeps.include ||= []
390
1041
 
391
- // inline-style-prefixer is CJS with __esModule and breaks without pre-bundling
392
- // (reference error: exports is not defined). always include it.
1042
+ // These dependencies are CJS and break when served directly to the browser
1043
+ // (`exports`/`module` is not defined). Pre-bundle them before Tamagui's linked
1044
+ // package graph can expose them as late-discovered transitive dependencies.
393
1045
  userConf.optimizeDeps.include.push('inline-style-prefixer')
1046
+ addIfInstalled(userConf, userConf.root, ['@react-native/normalize-color'])
394
1047
 
395
- // pre-bundle tamagui packages that use internal hooks (useThemeName, etc.)
396
- // from sub-entries, vite's dep crawler can otherwise split them into a
397
- // separate chunk with its own tamagui copy, producing two ThemeStateContext
398
- // instances and "Missing theme" errors at runtime.
1048
+ // pre-bundle core and web alongside tamagui packages that use internal
1049
+ // contexts and hooks. if either remains linked while these entries are
1050
+ // optimized, Provider imports and optimized consumers can receive
1051
+ // separate theme/component/config contexts even with resolve.dedupe.
399
1052
  //
400
1053
  // @tamagui/sheet/controller is the lightweight controller subpath imported
401
1054
  // by popover/dialog/select; the app imports @tamagui/sheet (full). if these
@@ -404,8 +1057,9 @@ export function tamaguiPlugin({
404
1057
  // and the Sheet consumer (from the full entry) never match and adapted
405
1058
  // sheets silently never open. include both so they share one context chunk.
406
1059
  addIfInstalled(userConf, userConf.root, [
1060
+ '@tamagui/core',
1061
+ '@tamagui/web',
407
1062
  '@tamagui/toast',
408
- '@tamagui/toast/v2',
409
1063
  '@tamagui/sheet',
410
1064
  '@tamagui/sheet/controller',
411
1065
  ])
@@ -439,9 +1093,155 @@ export function tamaguiPlugin({
439
1093
  config = resolvedConfig
440
1094
  },
441
1095
 
442
- async resolveId(source) {
443
- if (!shouldExtract) return
1096
+ async buildStart() {
1097
+ const buildConfig = this.environment.getTopLevelConfig()
1098
+ if (buildConfig.command !== 'build') return
1099
+
1100
+ const pendingCleanup = buildCleanupPromise
1101
+ if (pendingCleanup) {
1102
+ await pendingCleanup
1103
+ }
444
1104
 
1105
+ const buildEnvironment = this.environment
1106
+ activeBuildEnvironments.add(buildEnvironment)
1107
+ try {
1108
+ if (!tamaguiLoader.getEnvironment()) {
1109
+ await tamaguiLoader.loadTamaguiBuildConfig()
1110
+ buildEnvironmentPromise ||= (async () => {
1111
+ const evaluationConfig = await createOwnedEvaluationConfig(
1112
+ buildConfig,
1113
+ configuredEvaluationPackages
1114
+ )
1115
+ const evaluationEnvironment = createRunnableDevEnvironment(
1116
+ TAMAGUI_EVALUATION_ENVIRONMENT,
1117
+ evaluationConfig,
1118
+ { hot: false }
1119
+ )
1120
+ try {
1121
+ await evaluationEnvironment.init()
1122
+ } catch (error) {
1123
+ await evaluationEnvironment.close().catch(() => undefined)
1124
+ throw error
1125
+ }
1126
+ tamaguiLoader.setEnvironment(evaluationEnvironment, { owned: true })
1127
+ })()
1128
+ await buildEnvironmentPromise
1129
+ }
1130
+ } catch (error) {
1131
+ await releaseBuildEnvironment(buildEnvironment)
1132
+ throw error
1133
+ }
1134
+ },
1135
+
1136
+ hotUpdate: {
1137
+ order: 'post',
1138
+ async handler(options) {
1139
+ if (!tamaguiLoader.isEvaluationDependency(options.file)) {
1140
+ if (this.environment.name !== 'client') return
1141
+ const source = options.type === 'delete' ? null : await options.read()
1142
+ const affectedModules = new Set<EnvironmentModuleNode>()
1143
+ const compilerHmrRoots = new Set<string>(
1144
+ compilerFrontend.dependentsOf(options.file)
1145
+ )
1146
+ if (compilerHmrRoots.size || compilerFrontend.has(options.file)) {
1147
+ compilerHmrRoots.add(options.file)
1148
+ }
1149
+ if (compilerFrontend.has(options.file) || compilerHmrRoots.size > 0) {
1150
+ const loadedOptions = await ensureLoaded()
1151
+ if (!loadedOptions?.disable) {
1152
+ const invalidatedIds =
1153
+ options.type === 'delete'
1154
+ ? (await compilerFrontend.remove(options.file)).invalidatedIds
1155
+ : await compilerFrontend.update({
1156
+ id: options.file,
1157
+ source: source!,
1158
+ root: config.root,
1159
+ project: {
1160
+ ...(await tamaguiLoader.getCompilerProject()),
1161
+ generation: `${pluginInstanceId}:${tamaguiLoader.getGeneration()}`,
1162
+ },
1163
+ resolve: async (specifier, importer) => {
1164
+ const resolution =
1165
+ await this.environment.pluginContainer.resolveId(
1166
+ specifier,
1167
+ importer
1168
+ )
1169
+ return resolution
1170
+ ? { id: resolution.id, external: resolution.external === true }
1171
+ : null
1172
+ },
1173
+ load: async (dependencyId) => {
1174
+ const cleanDependencyId = dependencyId.split(/[?#]/, 1)[0]
1175
+ if (!path.isAbsolute(cleanDependencyId)) return null
1176
+ try {
1177
+ return await readFile(cleanDependencyId, 'utf8')
1178
+ } catch {
1179
+ return null
1180
+ }
1181
+ },
1182
+ })
1183
+ for (const invalidatedId of invalidatedIds) {
1184
+ for (const module of this.environment.moduleGraph.getModulesByFile(
1185
+ invalidatedId
1186
+ ) ?? []) {
1187
+ this.environment.moduleGraph.invalidateModule(module)
1188
+ if (compilerHmrRoots.has(invalidatedId) || module.isSelfAccepting) {
1189
+ affectedModules.add(module)
1190
+ }
1191
+ }
1192
+ const cssId = getAbsoluteVirtualFileId(`${invalidatedId}${virtualExt}`)
1193
+ const cssModule = this.environment.moduleGraph.getModuleById(cssId)
1194
+ if (cssModule) {
1195
+ this.environment.moduleGraph.invalidateModule(cssModule)
1196
+ }
1197
+ }
1198
+ }
1199
+ }
1200
+ return affectedModules.size ? [...affectedModules] : undefined
1201
+ }
1202
+
1203
+ const signature = await (async () => {
1204
+ if (options.type === 'delete') {
1205
+ return getHash(`${options.type}:${options.file}`)
1206
+ }
1207
+ try {
1208
+ return getHash(`${options.type}:${options.file}:${await options.read()}`)
1209
+ } catch {
1210
+ return getHash(`${options.type}:${options.file}:${options.timestamp}`)
1211
+ }
1212
+ })()
1213
+
1214
+ if (compilerHotUpdateSignatures.get(options.file) !== signature) {
1215
+ compilerHotUpdateSignatures.set(options.file, signature)
1216
+ tamaguiLoader.invalidate(options.file)
1217
+ invalidateCompilerModules()
1218
+ }
1219
+ if (
1220
+ this.environment.name === 'client' &&
1221
+ compilerHotReloadSignatures.get(options.file) !== signature
1222
+ ) {
1223
+ compilerHotReloadSignatures.set(options.file, signature)
1224
+ this.environment.hot.send({
1225
+ type: 'full-reload',
1226
+ path: '*',
1227
+ triggeredBy: options.file,
1228
+ })
1229
+ }
1230
+ return []
1231
+ },
1232
+ },
1233
+
1234
+ async watchChange(id) {
1235
+ if (config.command !== 'build') {
1236
+ return
1237
+ }
1238
+ if (tamaguiLoader.isEvaluationDependency(id)) {
1239
+ tamaguiLoader.invalidate(id)
1240
+ invalidateCompilerModules()
1241
+ }
1242
+ },
1243
+
1244
+ async resolveId(source) {
445
1245
  if (isNative(this.environment)) {
446
1246
  return
447
1247
  }
@@ -450,14 +1250,16 @@ export function tamaguiPlugin({
450
1250
  return
451
1251
  }
452
1252
 
1253
+ if (!shouldExtract) return
1254
+
453
1255
  const [validId, query] = source.split('?')
454
1256
 
455
1257
  if (!validId.endsWith(virtualExt)) {
456
1258
  return
457
1259
  }
458
1260
 
459
- const absoluteId = source.startsWith(config.root)
460
- ? source
1261
+ const absoluteId = validId.startsWith(config.root)
1262
+ ? validId
461
1263
  : getAbsoluteVirtualFileId(validId)
462
1264
 
463
1265
  if (cssMap.has(absoluteId)) {
@@ -466,9 +1268,7 @@ export function tamaguiPlugin({
466
1268
  },
467
1269
 
468
1270
  async load(id) {
469
- if (!shouldExtract) return
470
-
471
- const options = getTamaguiOptions()
1271
+ const options = tamaguiLoader.getTamaguiOptions()
472
1272
  if (options?.disable) {
473
1273
  return
474
1274
  }
@@ -481,183 +1281,499 @@ export function tamaguiPlugin({
481
1281
  return
482
1282
  }
483
1283
 
1284
+ if (!shouldExtract) return
1285
+
484
1286
  const [validId] = id.split('?')
485
1287
  return cssMap.get(validId)
486
1288
  },
1289
+ }
487
1290
 
1291
+ // Source and compiled JSX reach this filtered post-transform after user syntax
1292
+ // plugins and before Vite import analysis.
1293
+ const sharedCompilerPlugin: Plugin = {
1294
+ name: 'tamagui-compiler',
1295
+ enforce: 'post',
488
1296
  transform: {
489
1297
  order: 'pre',
490
1298
  async handler(code, id) {
491
- // ensure tamagui is loaded before transform
492
- const options = await ensureLoaded()
493
-
494
- // ensure full config (heavy bundling) is loaded before extraction
495
- await ensureFullConfigLoaded()
496
-
497
- // fully disabled = no extraction AND no debug attrs
498
- if (options?.disable) {
499
- return
500
- }
501
-
502
- if (isNative(this.environment)) {
503
- return
504
- }
1299
+ if (this.environment?.name === TAMAGUI_EVALUATION_ENVIRONMENT) return
1300
+ if (!tamaguiLoader.getEnvironment()) return
1301
+ if (isNative(this.environment)) return
505
1302
 
506
1303
  const [validId] = id.split('?')
507
- if (!validId.endsWith('.tsx')) {
1304
+ if (
1305
+ isFrameworkAnalysisRequest(id) ||
1306
+ !isAppJSXSource(validId) ||
1307
+ !/\.[jt]sx$/.test(validId)
1308
+ )
508
1309
  return
509
- }
1310
+ const options = await ensureLoaded()
1311
+ if (options?.disable || !shouldExtract) return
510
1312
 
511
- const { shouldDisable, shouldPrintDebug } = await getPragmaOptions({
1313
+ const { shouldDisable } = await Static.getPragmaOptions({
512
1314
  source: code,
513
1315
  path: validId,
514
1316
  })
1317
+ if (shouldDisable) return
515
1318
 
516
- if (shouldPrintDebug) {
517
- console.trace(
518
- `Current file: ${id} in environment: ${this.environment?.name}, shouldDisable: ${shouldDisable}`
519
- )
520
- console.info(`\n\nOriginal source:\n${code}\n\n`)
521
- }
522
-
523
- if (shouldDisable) {
524
- return
525
- }
526
-
527
- const isSSR = isNotClient(this.environment)
528
-
529
- // cache key without environment - share compiled JS between SSR/client
530
- const cacheKey = getHash(`${code}${id}`)
531
- const pending = getPendingExtractions()
532
-
533
- // helper to format result based on environment
534
- const formatResult = (entry: CacheEntry) => {
535
- const finalCode =
536
- !isSSR && entry.cssImport ? `${entry.js}\n${entry.cssImport}` : entry.js
537
- return { code: finalCode, map: entry.map }
538
- }
539
-
540
- // check cache first
541
- const cached = memoryCache[cacheKey]
542
- if (cached) {
543
- if (process.env.DEBUG_TAMAGUI_CACHE) {
544
- console.info(
545
- `[tamagui-cache] HIT ${this.environment?.name || 'unknown'} ${id.split('/').pop()} key=${cacheKey.slice(0, 8)}`
546
- )
547
- }
548
- return formatResult(cached)
549
- }
550
-
551
- // check if another request is already extracting this file
552
- const pendingExtraction = pending.get(cacheKey)
553
- if (pendingExtraction) {
554
- if (process.env.DEBUG_TAMAGUI_CACHE) {
555
- console.info(
556
- `[tamagui-cache] WAIT ${this.environment?.name || 'unknown'} ${id.split('/').pop()} key=${cacheKey.slice(0, 8)}`
557
- )
558
- }
559
- const result = await pendingExtraction
560
- if (result) {
561
- return formatResult(result)
562
- }
563
- return
1319
+ const evaluationDependencies = await tamaguiLoader.ensureFullConfigLoaded()
1320
+ for (const dependency of evaluationDependencies) this.addWatchFile(dependency)
1321
+ const compilerProject = await tamaguiLoader.getCompilerProject()
1322
+ const result = await compilerFrontend.compile({
1323
+ id: validId,
1324
+ source: code,
1325
+ root: config.root,
1326
+ target: 'web',
1327
+ project: {
1328
+ ...compilerProject,
1329
+ generation: `${pluginInstanceId}:${tamaguiLoader.getGeneration()}`,
1330
+ // `report` runs the same analysis as `enforce`, including the
1331
+ // mode-aware diagnostics, so both emit the identical violation list
1332
+ zeroRuntime: zero !== null,
1333
+ },
1334
+ resolve: async (specifier, importer) => {
1335
+ const resolution = await this.resolve(specifier, importer, { skipSelf: true })
1336
+ return resolution
1337
+ ? { id: resolution.id, external: resolution.external === true }
1338
+ : null
1339
+ },
1340
+ load: async (dependencyId) => {
1341
+ const cleanDependencyId = dependencyId.split(/[?#]/, 1)[0]
1342
+ if (!path.isAbsolute(cleanDependencyId)) return null
1343
+ try {
1344
+ return await readFile(cleanDependencyId, 'utf8')
1345
+ } catch {
1346
+ return null
1347
+ }
1348
+ },
1349
+ })
1350
+ transformedModuleIds.add(validId)
1351
+ compilerReports?.set(validId, {
1352
+ stats: result.plan.stats,
1353
+ diagnostics: result.plan.diagnostics,
1354
+ })
1355
+ for (const dependency of result.plan.dependencies) {
1356
+ if (path.isAbsolute(dependency)) this.addWatchFile(dependency)
564
1357
  }
565
1358
 
566
- if (process.env.DEBUG_TAMAGUI_CACHE) {
567
- console.info(
568
- `[tamagui-cache] EXTRACT ${this.environment?.name || 'unknown'} ${id.split('/').pop()} key=${cacheKey.slice(0, 8)}`
1359
+ // Island child build: the parent owns the one CSS artifact, so route this
1360
+ // module's atomic rules there and inject nothing.
1361
+ if (zeroIslandBuild) {
1362
+ zeroIslandBuild.artifact.setIslandModuleCSS(
1363
+ zeroIslandBuild.islandId,
1364
+ validId,
1365
+ wrapExtractedCSS(result.plan.css)
569
1366
  )
1367
+ return result.output.changed
1368
+ ? { code: result.output.code, map: result.output.map as any }
1369
+ : undefined
570
1370
  }
571
1371
 
572
- // create extraction promise and store it for deduplication
573
- const extractionPromise = (async (): Promise<CacheEntry | null> => {
574
- let extracted: ExtractedResponse | null
575
- try {
576
- extracted = await Static!.extractToClassNames({
577
- source: code,
578
- sourcePath: validId,
579
- options: options!,
580
- shouldPrintDebug,
581
- })
582
- } catch (err) {
583
- if (process.env.DEBUG_TAMAGUI_CACHE) {
584
- console.info(
585
- `[tamagui-cache] ERROR extracting ${id.split('/').pop()}:`,
586
- err
1372
+ if (zero) {
1373
+ const zeroResult = Static.transformZeroModule({
1374
+ mode: zero.isEnforcing ? 'enforce' : 'report',
1375
+ id: validId,
1376
+ root: config.root,
1377
+ source: code,
1378
+ plan: result.plan,
1379
+ config: (await tamaguiLoader.getTamaguiConfig())!,
1380
+ isTamaguiSpecifier: (specifier) =>
1381
+ specifier === 'tamagui' || specifier.startsWith('@tamagui/'),
1382
+ resolveIslandLoader: (specifier) => {
1383
+ const islandId = zero!.loaderIds.get(
1384
+ zeroModuleKey(path.resolve(path.dirname(validId), specifier))
587
1385
  )
588
- }
589
- console.error(err instanceof Error ? err.message : String(err))
590
- return null
1386
+ return islandId ? { islandId } : null
1387
+ },
1388
+ resolveIslandModule: (specifier) =>
1389
+ zero!.islandModuleIds.get(
1390
+ zeroModuleKey(path.resolve(path.dirname(validId), specifier))
1391
+ ) ?? null,
1392
+ })
1393
+ zero.transformed.add(validId)
1394
+ if (zeroResult.erased.exports.length) {
1395
+ zero.erasedExports.set(validId, zeroResult.erased.exports)
1396
+ }
1397
+ for (const violation of zeroResult.violations) {
1398
+ const { line, column } = Static.offsetToLineColumn(code, violation.span.start)
1399
+ zero.violations.push({
1400
+ file: path.relative(config.root, validId),
1401
+ line,
1402
+ column,
1403
+ rule: violation.rule,
1404
+ code: violation.code,
1405
+ component: violation.component,
1406
+ message: violation.message,
1407
+ })
591
1408
  }
1409
+ // `report` runs the same analysis and then leaves everything else
1410
+ // alone: full runtime, ordinary CSS handling, unchanged source. So it
1411
+ // falls through to the ordinary path below.
1412
+ if (zero.isEnforcing) {
1413
+ Static.mergeIslandBridges(zero.bridges, zeroResult.bridges)
1414
+ const moduleCSS = [
1415
+ wrapExtractedCSS(result.plan.css),
1416
+ ...[...zeroResult.bridgeCSS.values()],
1417
+ ]
1418
+ .filter(Boolean)
1419
+ .join('\n')
1420
+
1421
+ // Production combines every module's rules into the one artifact the
1422
+ // entry loads. Development keeps them on Vite's per-module CSS
1423
+ // modules, where the importer owns the ordering and hot replacement
1424
+ // already works.
1425
+ if (config.command !== 'build') {
1426
+ let cssImport = ''
1427
+ if (moduleCSS) {
1428
+ const rootRelativeId = `${validId}${virtualExt}`
1429
+ cssMap.set(getAbsoluteVirtualFileId(rootRelativeId), moduleCSS)
1430
+ this.addWatchFile(rootRelativeId)
1431
+ cssImport = `\nimport "${rootRelativeId}";`
1432
+ }
1433
+ return {
1434
+ code: `${zeroResult.output.code}${cssImport}`,
1435
+ map: zeroResult.output.map as any,
1436
+ }
1437
+ }
592
1438
 
593
- if (!extracted) {
594
- if (process.env.DEBUG_TAMAGUI_CACHE) {
595
- console.info(
596
- `[tamagui-cache] no extraction result for ${id.split('/').pop()}`
597
- )
1439
+ for (const [identifier, rules] of zeroResult.bridgeCSS) {
1440
+ zero.artifact.setBridgeRules(identifier, rules)
598
1441
  }
599
- return null
1442
+ zero.artifact.setZeroModuleCSS(validId, wrapExtractedCSS(result.plan.css))
1443
+ return zeroResult.output.changed
1444
+ ? { code: zeroResult.output.code, map: zeroResult.output.map as any }
1445
+ : undefined
600
1446
  }
1447
+ }
601
1448
 
1449
+ const isSSR = isNotClient(this.environment)
1450
+ let cssImport: string | null = null
1451
+ if (result.plan.css) {
602
1452
  const rootRelativeId = `${validId}${virtualExt}`
603
1453
  const absoluteId = getAbsoluteVirtualFileId(rootRelativeId)
1454
+ cssMap.set(absoluteId, wrapExtractedCSS(result.plan.css))
1455
+ this.addWatchFile(rootRelativeId)
1456
+ if (!isSSR) cssImport = `import "${rootRelativeId}";`
1457
+ }
1458
+ const finalCode = cssImport
1459
+ ? `${result.output.code}\n${cssImport}`
1460
+ : result.output.code
1461
+ return result.output.changed || cssImport
1462
+ ? { code: finalCode, map: result.output.map as any }
1463
+ : undefined
1464
+ },
1465
+ },
1466
+ }
604
1467
 
605
- let cssImport: string | null = null
1468
+ // Owns the single CSS artifact, the island child builds, and the module-graph
1469
+ // gate that is the only thing that actually proves the zero guarantee.
1470
+ //
1471
+ // Development runs the same lowering and reference erasure, so the runtime
1472
+ // that generates design-system, :root, font and theme CSS is gone there too.
1473
+ // The dev server therefore has to serve that CSS itself: it publishes the
1474
+ // config half at the same href production uses and builds the islands once at
1475
+ // startup. Per-module atomic rules keep Vite's own `.tamagui.css` modules in
1476
+ // dev, which is where hot replacement already works; production combines them
1477
+ // into the one artifact instead.
1478
+ const zeroRuntimePlugin: Plugin = {
1479
+ name: 'tamagui-zero-runtime',
1480
+ enforce: 'post',
606
1481
 
607
- // store CSS and prepare import (but don't include in cached JS)
608
- if (extracted.styles) {
609
- this.addWatchFile(rootRelativeId)
1482
+ async buildStart() {
1483
+ if (!zero || this.environment.name !== 'client') return
1484
+ await tamaguiLoader.ensureFullConfigLoaded()
1485
+ const tamaguiConfig = await tamaguiLoader.getTamaguiConfig()
1486
+ if (!tamaguiConfig) {
1487
+ throw new Error(
1488
+ `[tamagui zero-runtime] the Tamagui config did not evaluate, so no CSS artifact can be generated`
1489
+ )
1490
+ }
1491
+ zero.violations.length = 0
1492
+ zero.transformed.clear()
1493
+ zero.erasedExports.clear()
1494
+ if (!zero.isEnforcing) return
1495
+ Static.assertZeroConfigDrivers(tamaguiConfig)
1496
+ zero.artifact.clearGraphs()
1497
+ zero.bridges.clear()
1498
+ zeroHtmlEntries = 0
1499
+ zero.artifact.setConfigCSS(tamaguiConfig.getCSS())
1500
+
1501
+ // Production builds the islands at the end, once the zero graph is known.
1502
+ // Development has no such end, so they are built here, after the reset
1503
+ // that would otherwise discard their rules, and the dev server's artifact
1504
+ // route waits on this.
1505
+ if (config.command !== 'build') {
1506
+ const islands = zero
1507
+ zeroDevIslands = Promise.all(
1508
+ islands.resolved.islands.map((island) =>
1509
+ buildIsland({
1510
+ island,
1511
+ controller: islands,
1512
+ root: config.root,
1513
+ outDir: zeroDevIslandDir(islands),
1514
+ mode: 'development',
1515
+ })
1516
+ )
1517
+ )
1518
+ await zeroDevIslands
1519
+ }
1520
+ },
610
1521
 
611
- if (server && cssMap.has(absoluteId)) {
612
- invalidateModule(rootRelativeId)
613
- }
1522
+ async configureServer(devServer) {
1523
+ if (!zero?.isEnforcing) return
1524
+ const islandBase = `${zero.cssHref.replace(ZERO_CSS_FILENAME, '')}${ZERO_ISLAND_DIRNAME}/`
1525
+ devServer.middlewares.use(async (request, response, next) => {
1526
+ const url = (request.url || '').split('?')[0]
1527
+ if (url !== zero!.cssHref && !url.startsWith(islandBase)) return next()
1528
+ // buildStart owns the artifact's contents and the island builds, so a
1529
+ // request that arrives first waits for it rather than reading a
1530
+ // half-populated artifact
1531
+ await zeroDevIslands
1532
+ if (url === zero!.cssHref) {
1533
+ response.setHeader('content-type', 'text/css; charset=utf-8')
1534
+ response.setHeader('cache-control', 'no-cache')
1535
+ response.end(zero!.artifact.css())
1536
+ return
1537
+ }
1538
+ const islandId = url.slice(islandBase.length).replace(/\.js$/, '')
1539
+ const file = path.join(
1540
+ zeroDevIslandDir(zero!),
1541
+ ZERO_ISLAND_DIRNAME,
1542
+ `${islandId}.js`
1543
+ )
1544
+ if (!existsSync(file)) return next()
1545
+ response.setHeader('content-type', 'text/javascript; charset=utf-8')
1546
+ response.setHeader('cache-control', 'no-cache')
1547
+ response.end(readFileSync(file))
1548
+ })
1549
+ },
614
1550
 
615
- cssImport = `import "${rootRelativeId}";`
616
- cssMap.set(absoluteId, extracted.styles)
617
- }
1551
+ // The last hook that still sees the resolved graph and runs before rolldown
1552
+ // renders chunks. An erased export that some module still imports has to be
1553
+ // reported here: by render time the bundler has already failed on it with a
1554
+ // message about a missing export, which says nothing about why it is missing.
1555
+ buildEnd(error) {
1556
+ if (!zero || this.environment.name !== 'client') return
1557
+ if (error) {
1558
+ zeroBuildFailed = true
1559
+ return
1560
+ }
1561
+ if (!zero.isEnforcing) return
1562
+ const importers = new Map<string, readonly string[]>()
1563
+ for (const moduleId of this.getModuleIds()) {
1564
+ importers.set(moduleId, this.getModuleInfo(moduleId)?.importers ?? [])
1565
+ }
1566
+ const escape = Static.erasedExportEscape({
1567
+ integration: 'vite',
1568
+ transformed: zero.transformed,
1569
+ erasedExports: zero.erasedExports,
1570
+ importersOf: importers,
1571
+ })
1572
+ if (escape) {
1573
+ zeroBuildFailed = true
1574
+ throw new Error(escape)
1575
+ }
1576
+ },
618
1577
 
619
- // cache the JS separately from CSS import
620
- const jsCode = extracted.js.toString()
621
- const cacheEntry: CacheEntry = {
622
- js: jsCode,
623
- map: extracted.map,
624
- cssImport,
625
- }
1578
+ transformIndexHtml: {
1579
+ order: 'post',
1580
+ handler(html) {
1581
+ if (!zero?.isEnforcing) return
1582
+ zeroHtmlEntries++
1583
+ return {
1584
+ html,
1585
+ tags: [
1586
+ {
1587
+ tag: 'link',
1588
+ attrs: { rel: 'stylesheet', href: zero.cssHref },
1589
+ injectTo: 'head',
1590
+ },
1591
+ ],
1592
+ }
1593
+ },
1594
+ },
626
1595
 
627
- // track cache size and clear if too large (64MB)
628
- const newSize = getSharedCacheSize() + jsCode.length
629
- if (newSize > 67108864) {
630
- clearSharedCache()
631
- } else {
632
- setSharedCacheSize(newSize)
633
- }
634
- memoryCache[cacheKey] = cacheEntry
1596
+ generateBundle(_outputOptions, bundle) {
1597
+ if (!zero?.isEnforcing || this.environment.name !== 'client') return
1598
+ // rolldown reports the modules that contributed rendered code per chunk,
1599
+ // which is exactly what shipped. Importer edges come from the whole
1600
+ // resolved graph so a forbidden module can name its shortest chain.
1601
+ const importers = new Map<string, string[]>()
1602
+ for (const moduleId of this.getModuleIds()) {
1603
+ for (const imported of this.getModuleInfo(moduleId)?.importedIds ?? []) {
1604
+ const list = importers.get(imported)
1605
+ if (list) list.push(moduleId)
1606
+ else importers.set(imported, [moduleId])
1607
+ }
1608
+ }
1609
+ const entries: string[] = []
1610
+ const modules: { id: string; importers: readonly string[] }[] = []
1611
+ for (const chunk of Object.values(bundle)) {
1612
+ if (chunk.type !== 'chunk') continue
1613
+ for (const moduleId of Object.keys(chunk.modules)) {
1614
+ modules.push({ id: moduleId, importers: importers.get(moduleId) ?? [] })
1615
+ if (this.getModuleInfo(moduleId)?.isEntry) entries.push(moduleId)
1616
+ }
1617
+ }
1618
+ const checked = Static.checkZeroGraph({
1619
+ entries,
1620
+ modules,
1621
+ importerEdges: importers,
1622
+ root: zero.resolved.root,
1623
+ })
1624
+ zeroReceipt = {
1625
+ integration: 'vite',
1626
+ graph: 'zero',
1627
+ entries: entries.sort(),
1628
+ moduleCount: modules.length,
1629
+ tamaguiModules: checked.tamaguiModules,
1630
+ forbidden: checked.forbidden,
1631
+ cssArtifact: null,
1632
+ identity: '',
1633
+ gzip: Object.fromEntries(
1634
+ Object.values(bundle)
1635
+ .filter((chunk) => chunk.type === 'chunk')
1636
+ .map((chunk) => [
1637
+ chunk.fileName,
1638
+ gzipSync(Buffer.from((chunk as any).code), { level: 9 }).length,
1639
+ ])
1640
+ ),
1641
+ }
1642
+ },
635
1643
 
636
- if (process.env.DEBUG_TAMAGUI_CACHE) {
637
- console.info(
638
- `[tamagui-cache] WRITE key=${cacheKey.slice(0, 8)} cacheSize=${Object.keys(memoryCache).length}`
639
- )
640
- }
1644
+ async closeBundle() {
1645
+ if (!zero || this.environment.name !== 'client') return
1646
+ const outDir = path.resolve(config.root, this.environment.config.build.outDir)
1647
+ // one receipt per output directory, so a zero build and its negative
1648
+ // control never overwrite each other's evidence
1649
+ const receiptName = `vite-${path.basename(outDir)}`
1650
+ // Written in both modes and before the failure, so `report` and `enforce`
1651
+ // emit the identical list and only their exit differs.
1652
+ Static.writeZeroViolationReport(zero.resolved.outDir, receiptName, {
1653
+ integration: 'vite',
1654
+ mode: zero.isEnforcing ? 'enforce' : 'report',
1655
+ violations: zero.violations,
1656
+ })
1657
+ if (!zero.isEnforcing || zeroBuildFailed) return
1658
+ if (zero.violations.length) {
1659
+ throw new Error(Static.formatZeroViolations(zero.violations))
1660
+ }
1661
+ const islandOutputHashes: Record<string, string> = {}
1662
+ for (const island of zero.resolved.islands) {
1663
+ const built = await buildIsland({
1664
+ island,
1665
+ controller: zero,
1666
+ root: config.root,
1667
+ outDir,
1668
+ mode: config.mode,
1669
+ })
1670
+ islandOutputHashes[island.id] = built.hash
1671
+ }
641
1672
 
642
- return cacheEntry
643
- })()
1673
+ // The plugin, not the app, injects the zero artifact's stylesheet link, so
1674
+ // an entry graph with no HTML entry strips the rules and loads nothing.
1675
+ if (zeroHtmlEntries === 0) {
1676
+ throw new Error(
1677
+ `[tamagui zero-runtime] the zero entry graph has no HTML entry, so the one generated CSS artifact ${zero.cssHref} is never loaded. Build a zero entry through its HTML document.`
1678
+ )
1679
+ }
644
1680
 
645
- // store pending promise for deduplication
646
- pending.set(cacheKey, extractionPromise)
1681
+ const css = finalizeZeroCSS(zero, outDir)
1682
+ const bridgeManifest = Static.canonicalizeBridgeManifest(
1683
+ Object.fromEntries(
1684
+ [...zero.bridges.entries()].sort(([left], [right]) => (left < right ? -1 : 1))
1685
+ )
1686
+ )
1687
+ const identityInputs = {
1688
+ runtimeLiteral: 'zero' as const,
1689
+ target: 'web' as const,
1690
+ configGeneration: `${pluginInstanceId}:${tamaguiLoader.getGeneration()}`,
1691
+ cssHash: css.hash,
1692
+ compilerVersion: Static.ZERO_COMPILER_VERSION,
1693
+ islandEntries: zero.resolved.islands.map((island) => island.module),
1694
+ bridgeManifestHash: Static.hashBridgeManifest(bridgeManifest),
1695
+ islandOutputHashes,
1696
+ }
1697
+ const identity = Static.hashZeroIdentity(identityInputs)
647
1698
 
648
- try {
649
- const result = await extractionPromise
650
- if (result) {
651
- return formatResult(result)
652
- }
653
- return
654
- } finally {
655
- // clean up pending map
656
- pending.delete(cacheKey)
657
- }
658
- },
1699
+ if (!zeroReceipt) {
1700
+ throw new Error(
1701
+ `[tamagui zero-runtime] no module graph was recorded for the zero entry`
1702
+ )
1703
+ }
1704
+ zeroReceipt.cssArtifact = { path: css.href, hash: css.hash }
1705
+ zeroReceipt.identity = identity
1706
+ Static.writeZeroGraphReceipt(zero.resolved.outDir, receiptName, zeroReceipt)
1707
+ writeFileSync(
1708
+ path.join(zero.resolved.outDir, `${receiptName}.bridges.json`),
1709
+ `${JSON.stringify(
1710
+ { identity, identityInputs, cssGzip: css.gzip, bridges: bridgeManifest },
1711
+ null,
1712
+ 2
1713
+ )}\n`
1714
+ )
1715
+ assertZeroGraph(zeroReceipt)
659
1716
  },
660
1717
  }
661
1718
 
662
- return [basePlugin, rnwLitePlugin, extractPlugin]
1719
+ // The compiled-global-CSS tier. `TAMAGUI_DID_OUTPUT_CSS` was already inlined
1720
+ // in the client environment, so this proves the artifact that replaces those
1721
+ // stripped rules exists, matches this build's config, and is in the graph.
1722
+ const globalCSSPlugin: Plugin = {
1723
+ name: 'tamagui-global-css',
1724
+ enforce: 'post',
1725
+ apply: 'build',
1726
+
1727
+ async buildStart() {
1728
+ if (!globalCSS || this.environment.name !== 'client') return
1729
+ await tamaguiLoader.ensureFullConfigLoaded()
1730
+ const tamaguiConfig = await tamaguiLoader.getTamaguiConfig()
1731
+ if (!tamaguiConfig) {
1732
+ throw new Error(
1733
+ `[tamagui] outputCSS is set but the Tamagui config did not evaluate, so no CSS artifact can be generated`
1734
+ )
1735
+ }
1736
+ globalCSSExpected = tamaguiConfig.getCSS()
1737
+ },
1738
+
1739
+ generateBundle() {
1740
+ if (!globalCSS || this.environment.name !== 'client') return
1741
+ const failure = Static.checkGlobalCSSArtifact({
1742
+ cssPath: globalCSS.cssPath,
1743
+ expectedCSS: globalCSSExpected ?? '',
1744
+ loadedModuleIds: this.getModuleIds(),
1745
+ importHint: `Import it once from your client entry: import ${JSON.stringify(
1746
+ relativeImportSpecifier(config.root, globalCSS.cssPath)
1747
+ )}`,
1748
+ })
1749
+ if (failure) throw new Error(failure.message)
1750
+ },
1751
+ }
1752
+
1753
+ return {
1754
+ plugins: [
1755
+ basePlugin,
1756
+ rnwLitePlugin,
1757
+ extractPlugin,
1758
+ sharedCompilerPlugin,
1759
+ zeroRuntimePlugin,
1760
+ globalCSSPlugin,
1761
+ tamaguiNativePlugin(tamaguiOptionsIn),
1762
+ ],
1763
+ loader: tamaguiLoader,
1764
+ }
1765
+ }
1766
+
1767
+ /** Where the dev server's island bundles are built and served from. */
1768
+ function zeroDevIslandDir(zero: ZeroRuntimeController) {
1769
+ return path.join(zero.resolved.outDir, 'dev')
1770
+ }
1771
+
1772
+ function relativeImportSpecifier(from: string, to: string) {
1773
+ const relative = normalizePath(path.relative(from, to))
1774
+ return relative.startsWith('.') ? relative : `./${relative}`
1775
+ }
1776
+
1777
+ export function tamaguiPlugin(options: TamaguiVitePluginOptions = {}): PluginOption {
1778
+ return createTamaguiPlugins(options).plugins
663
1779
  }