@tamagui/vite-plugin 2.7.7 → 3.0.0-beta.1097.1

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