@vxrn/vite-plugin-metro 1.25.16 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/cjs/metro-config/getMetroConfigFromViteConfig.cjs +20 -0
  2. package/dist/cjs/metro-config/getMetroConfigFromViteConfig.native.js +20 -0
  3. package/dist/cjs/metro-config/getMetroConfigFromViteConfig.native.js.map +1 -1
  4. package/dist/cjs/metro-config/patchMetroServerWithViteConfigAndMetroPluginOptions.cjs +2 -1
  5. package/dist/cjs/metro-config/patchMetroServerWithViteConfigAndMetroPluginOptions.native.js +2 -1
  6. package/dist/cjs/metro-config/patchMetroServerWithViteConfigAndMetroPluginOptions.native.js.map +1 -1
  7. package/dist/cjs/plugins/metroPlugin.native.js.map +1 -1
  8. package/dist/cjs/transformer/metroNativeWorker.cjs +1590 -0
  9. package/dist/cjs/transformer/metroNativeWorker.native.js +2674 -0
  10. package/dist/cjs/transformer/metroNativeWorker.native.js.map +1 -0
  11. package/dist/cjs/transformer/metroNativeWorker.test.cjs +886 -0
  12. package/dist/cjs/transformer/metroNativeWorker.test.native.js +995 -0
  13. package/dist/cjs/transformer/metroNativeWorker.test.native.js.map +1 -0
  14. package/dist/esm/metro-config/getMetroConfigFromViteConfig.mjs +20 -0
  15. package/dist/esm/metro-config/getMetroConfigFromViteConfig.mjs.map +1 -1
  16. package/dist/esm/metro-config/getMetroConfigFromViteConfig.native.js +20 -0
  17. package/dist/esm/metro-config/getMetroConfigFromViteConfig.native.js.map +1 -1
  18. package/dist/esm/metro-config/patchMetroServerWithViteConfigAndMetroPluginOptions.mjs +2 -1
  19. package/dist/esm/metro-config/patchMetroServerWithViteConfigAndMetroPluginOptions.mjs.map +1 -1
  20. package/dist/esm/metro-config/patchMetroServerWithViteConfigAndMetroPluginOptions.native.js +2 -1
  21. package/dist/esm/metro-config/patchMetroServerWithViteConfigAndMetroPluginOptions.native.js.map +1 -1
  22. package/dist/esm/plugins/metroPlugin.mjs.map +1 -1
  23. package/dist/esm/plugins/metroPlugin.native.js.map +1 -1
  24. package/dist/esm/transformer/metroNativeWorker.mjs +1531 -0
  25. package/dist/esm/transformer/metroNativeWorker.mjs.map +1 -0
  26. package/dist/esm/transformer/metroNativeWorker.native.js +2612 -0
  27. package/dist/esm/transformer/metroNativeWorker.native.js.map +1 -0
  28. package/dist/esm/transformer/metroNativeWorker.test.mjs +850 -0
  29. package/dist/esm/transformer/metroNativeWorker.test.mjs.map +1 -0
  30. package/dist/esm/transformer/metroNativeWorker.test.native.js +957 -0
  31. package/dist/esm/transformer/metroNativeWorker.test.native.js.map +1 -0
  32. package/package.json +22 -3
  33. package/src/metro-config/getMetroConfigFromViteConfig.ts +42 -0
  34. package/src/metro-config/patchMetroServerWithViteConfigAndMetroPluginOptions.ts +1 -0
  35. package/src/plugins/metroPlugin.ts +22 -0
  36. package/src/transformer/__fixtures__/exampleNativeTransform.cjs +17 -0
  37. package/src/transformer/metroNativeWorker.test.ts +1200 -0
  38. package/src/transformer/metroNativeWorker.ts +2379 -0
  39. package/src/transformer/types.ts +21 -0
  40. package/types/metro-config/getMetroConfigFromViteConfig.d.ts.map +1 -1
  41. package/types/metro-config/patchMetroServerWithViteConfigAndMetroPluginOptions.d.ts.map +1 -1
  42. package/types/plugins/metroPlugin.d.ts +22 -0
  43. package/types/plugins/metroPlugin.d.ts.map +1 -1
  44. package/types/transformer/metroNativeWorker.d.ts +251 -0
  45. package/types/transformer/metroNativeWorker.d.ts.map +1 -0
  46. package/types/transformer/metroNativeWorker.test.d.ts +2 -0
  47. package/types/transformer/metroNativeWorker.test.d.ts.map +1 -0
  48. package/types/transformer/types.d.ts +17 -0
  49. package/types/transformer/types.d.ts.map +1 -1
@@ -0,0 +1,1200 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import * as babel from '@babel/core'
3
+ import * as vm from 'node:vm'
4
+ import { parseSync } from 'oxc-parser'
5
+ import * as fs from 'node:fs'
6
+ import * as path from 'node:path'
7
+ import { createRequire } from 'node:module'
8
+ import {
9
+ transform,
10
+ getCacheKey,
11
+ wrapModule,
12
+ wrapJson,
13
+ extractDependencies,
14
+ rewriteDependencyCalls,
15
+ countLinesAndTerminateMap,
16
+ applyInlineEnvVars,
17
+ assertNoUnportedBabelPlugins,
18
+ applyEnvironmentGuard,
19
+ getRemoveServerCodeRouterRoot,
20
+ } from './metroNativeWorker'
21
+ import { buildMetroConfigInputFromViteConfig } from '../metro-config/getMetroConfigFromViteConfig'
22
+
23
+ const babelCalls = {
24
+ transform: 0,
25
+ transformSync: 0,
26
+ transformAsync: 0,
27
+ transformFromAstSync: 0,
28
+ parse: 0,
29
+ parseSync: 0,
30
+ }
31
+
32
+ vi.mock('@babel/core', async (importOriginal) => {
33
+ const actual = await importOriginal<typeof import('@babel/core')>()
34
+ return {
35
+ ...actual,
36
+ transform: vi.fn((...args: any[]) => {
37
+ babelCalls.transform++
38
+ return (actual.transform as any)(...args)
39
+ }),
40
+ transformSync: vi.fn((...args: any[]) => {
41
+ babelCalls.transformSync++
42
+ return (actual.transformSync as any)(...args)
43
+ }),
44
+ transformAsync: vi.fn((...args: any[]) => {
45
+ babelCalls.transformAsync++
46
+ return (actual.transformAsync as any)(...args)
47
+ }),
48
+ transformFromAstSync: vi.fn((...args: any[]) => {
49
+ babelCalls.transformFromAstSync++
50
+ return (actual.transformFromAstSync as any)(...args)
51
+ }),
52
+ parse: vi.fn((...args: any[]) => {
53
+ babelCalls.parse++
54
+ return (actual.parse as any)(...args)
55
+ }),
56
+ parseSync: vi.fn((...args: any[]) => {
57
+ babelCalls.parseSync++
58
+ return (actual.parseSync as any)(...args)
59
+ }),
60
+ }
61
+ })
62
+
63
+ // module scope so every describe in this file can assert it, not just the first
64
+ beforeEach(() => {
65
+ babelCalls.transform = 0
66
+ babelCalls.transformSync = 0
67
+ babelCalls.transformAsync = 0
68
+ babelCalls.transformFromAstSync = 0
69
+ babelCalls.parse = 0
70
+ babelCalls.parseSync = 0
71
+ })
72
+
73
+ function assertZeroBabelCalls() {
74
+ expect(babelCalls.transform).toBe(0)
75
+ expect(babelCalls.transformSync).toBe(0)
76
+ expect(babelCalls.transformAsync).toBe(0)
77
+ expect(babelCalls.transformFromAstSync).toBe(0)
78
+ expect(babelCalls.parse).toBe(0)
79
+ expect(babelCalls.parseSync).toBe(0)
80
+ }
81
+
82
+ describe('metroNativeWorker', () => {
83
+ it.each([['data.json', true], ['data.json', false], ['data.js', false]] as const)('minifies %s with source maps enabled=%s without stale mappings', async (filename, sourceMap) => {
84
+ const result = await transform(
85
+ {
86
+ minifierPath: createRequire(import.meta.url).resolve('metro-minify-terser'),
87
+ minifierConfig: { sourceMap },
88
+ },
89
+ '/project',
90
+ filename,
91
+ Buffer.from(filename.endsWith('.json') ? '{"answer":42}' : 'module.exports = { answer: 42 }'),
92
+ { dev: false, minify: true, platform: 'ios', type: 'module' }
93
+ )
94
+ const module = { exports: {} as any }
95
+ const { code, map } = result.output[0].data
96
+ vm.runInNewContext(code, {
97
+ __d(factory: any) {
98
+ factory({}, () => {}, () => {}, () => {}, module, module.exports, [])
99
+ },
100
+ })
101
+ expect(module.exports.answer).toBe(42)
102
+ expect(map.every((entry) => entry.length === 2)).toBe(true)
103
+ assertZeroBabelCalls()
104
+ })
105
+
106
+ it('minifies production modules with executable output and original source locations', async () => {
107
+ const source = 'exports.answer = function answer(value) {\n return value + 42\n}\n'
108
+ const result = await transform(
109
+ {
110
+ minifierPath: createRequire(import.meta.url).resolve('metro-minify-terser'),
111
+ minifierConfig: { mangle: true },
112
+ },
113
+ '/project',
114
+ 'answer.js',
115
+ Buffer.from(source),
116
+ { dev: false, minify: true, platform: 'ios', type: 'module' }
117
+ )
118
+ const { code, map } = result.output[0].data
119
+ const module = { exports: {} as any }
120
+ vm.runInNewContext(code, {
121
+ __d(factory: any) {
122
+ factory({}, () => {}, () => {}, () => {}, module, module.exports, [])
123
+ },
124
+ })
125
+ expect(module.exports.answer(8)).toBe(50)
126
+
127
+ const msm = await import('metro-source-map')
128
+ const fullMap = (msm.fromRawMappings as any)([
129
+ { code, path: 'answer.js', source, map },
130
+ ]).toMap()
131
+ const offset = code.indexOf('42')
132
+ expect(offset).toBeGreaterThan(-1)
133
+ const prefix = code.slice(0, offset).split('\n')
134
+ const position = new msm.Consumer(fullMap).originalPositionFor({
135
+ line: prefix.length as any,
136
+ column: prefix.at(-1)!.length as any,
137
+ })
138
+ expect(position.source).toBe('answer.js')
139
+ expect(position.line).toBe(2)
140
+ expect(position.column).toBe(17)
141
+ assertZeroBabelCalls()
142
+ })
143
+
144
+ it('extracts all dependencies with oxc-parser and zero Babel', () => {
145
+ const code = `
146
+ import React, { useState } from 'react'
147
+ import * as RN from 'react-native'
148
+ import './global.css'
149
+ import type { UserType } from './user-types'
150
+ export { Button } from './Button'
151
+ export * from './icons'
152
+ export type { ThemeType } from './theme-types'
153
+ const helper = require('./helper')
154
+ async function loadAsync() {
155
+ const dyn = await import('./dynamic-module')
156
+ }
157
+ `
158
+
159
+ const deps = extractDependencies(code, 'TestComponent.tsx')
160
+ assertZeroBabelCalls()
161
+
162
+ const names = deps.map((d) => d.name)
163
+ expect(names).toContain('react')
164
+ expect(names).toContain('react-native')
165
+ expect(names).toContain('./global.css')
166
+ expect(names).toContain('./Button')
167
+ expect(names).toContain('./icons')
168
+ expect(names).toContain('./helper')
169
+ expect(names).toContain('./dynamic-module')
170
+
171
+ // Type-only imports and exports should NOT be extracted as runtime dependencies
172
+ expect(names).not.toContain('./user-types')
173
+ expect(names).not.toContain('./theme-types')
174
+
175
+ // ESM import flags
176
+ const reactDep = deps.find((d) => d.name === 'react')!
177
+ expect(reactDep.data.isESMImportAtSource).toBe(true)
178
+ expect(reactDep.data.asyncType).toBeNull()
179
+ expect(reactDep.data.locs.length).toBeGreaterThan(0)
180
+ // metro's unable-to-resolve error path reads loc.start.line and loc.end.line,
181
+ // so a flat {line, column} makes that error throw and hide the real failure.
182
+ const loc = reactDep.data.locs[0]
183
+ expect(loc.start.line).toBeGreaterThan(0)
184
+ expect(loc.start.column).toBeGreaterThanOrEqual(0)
185
+ expect(loc.end.line).toBeGreaterThanOrEqual(loc.start.line)
186
+ expect(loc.end.column).toBeGreaterThanOrEqual(0)
187
+
188
+ // CJS require flag
189
+ const helperDep = deps.find((d) => d.name === './helper')!
190
+ expect(helperDep.data.isESMImportAtSource).toBe(false)
191
+ expect(helperDep.data.asyncType).toBeNull()
192
+
193
+ // Dynamic import flag
194
+ const dynamicDep = deps.find((d) => d.name === './dynamic-module')!
195
+ expect(dynamicDep.data.asyncType).toBe('async')
196
+ })
197
+
198
+ it('marks requires inside a try block optional', () => {
199
+ // optional native modules are required exactly this way. without
200
+ // data.isOptional metro fails the whole build when one is absent.
201
+ const code = `
202
+ let worklets
203
+ try {
204
+ worklets = require('react-native-worklets-core')
205
+ } catch {}
206
+ const always = require('./always-there')
207
+ `
208
+
209
+ const off = extractDependencies(code, 'setup.js')
210
+ expect(off.find((d) => d.name === 'react-native-worklets-core')!.data.isOptional).toBeUndefined()
211
+
212
+ const on = extractDependencies(code, 'setup.js', { allowOptionalDependencies: true })
213
+ expect(on.find((d) => d.name === 'react-native-worklets-core')!.data.isOptional).toBe(true)
214
+ expect(on.find((d) => d.name === './always-there')!.data.isOptional).toBeUndefined()
215
+
216
+ const excluded = extractDependencies(code, 'setup.js', {
217
+ allowOptionalDependencies: { exclude: ['react-native-worklets-core'] },
218
+ })
219
+ expect(
220
+ excluded.find((d) => d.name === 'react-native-worklets-core')!.data.isOptional
221
+ ).toBeUndefined()
222
+ })
223
+
224
+ it('wraps modules in Metro CommonJS format with and without moduleId', () => {
225
+ const code = 'exports.val = 123;'
226
+
227
+ const wrappedWithoutId = wrapModule(code)
228
+ expect(wrappedWithoutId).toContain(
229
+ '__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {'
230
+ )
231
+ expect(wrappedWithoutId).toContain('var require = _$$_REQUIRE;')
232
+ expect(wrappedWithoutId).toContain('exports.val = 123;')
233
+ expect(wrappedWithoutId.endsWith(');')).toBe(true)
234
+
235
+ const wrappedWithId = wrapModule(code, {
236
+ moduleId: 99,
237
+ dependencyIds: [1, 2, 3],
238
+ })
239
+ expect(wrappedWithId).toContain(', 99, [1,2,3]);')
240
+ })
241
+
242
+ it('transforms TSX with React JSX, Hermes lowering, and ZERO Babel', async () => {
243
+ const sourceCode = `
244
+ import React from 'react'
245
+ import { View, Text } from 'react-native'
246
+ const helper = require('./helper')
247
+
248
+ export class Greeter extends React.Component {
249
+ greeting = 'Hello from Native'
250
+
251
+ async fetchGreeting() {
252
+ return this.greeting
253
+ }
254
+
255
+ render() {
256
+ return (
257
+ <View testID="greeter">
258
+ <Text>{this.greeting}</Text>
259
+ </View>
260
+ )
261
+ }
262
+ }
263
+ `
264
+
265
+ const result = await transform(
266
+ {},
267
+ '/project',
268
+ 'Greeter.tsx',
269
+ Buffer.from(sourceCode, 'utf8'),
270
+ { dev: true, platform: 'ios', type: 'module' }
271
+ )
272
+
273
+ // Verify ZERO Babel calls
274
+ assertZeroBabelCalls()
275
+
276
+ expect(result.output).toHaveLength(1)
277
+ const output = result.output[0]
278
+ expect(output.type).toBe('js/module')
279
+ expect(output.data.lineCount).toBeGreaterThan(0)
280
+ expect(output.data.map.length).toBeGreaterThan(0)
281
+
282
+ const code = output.data.code
283
+
284
+ // Module wrapping check
285
+ expect(code).toContain(
286
+ '__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {'
287
+ )
288
+ expect(code).toContain('var require = _$$_REQUIRE;')
289
+
290
+ // JSX transformed (no raw JSX tags remain)
291
+ expect(code).not.toContain('<View')
292
+ expect(code).not.toContain('<Text')
293
+
294
+ // Hermes lowering applied (class field property lowered to constructor)
295
+ expect(code).toContain('this.greeting = "Hello from Native"')
296
+
297
+ // Dependencies extracted
298
+ const depNames = result.dependencies.map((d) => d.name)
299
+ expect(depNames).toContain('react')
300
+ expect(depNames).toContain('react-native')
301
+ expect(depNames).toContain('./helper')
302
+ expect(depNames).toContain('react/jsx-dev-runtime')
303
+ })
304
+
305
+ it('lowers async generators, including the method shorthand Hermes also rejects', async () => {
306
+ // each form goes through on its own, so a detector that misses one cannot be
307
+ // covered for by another form sharing the module.
308
+ const sources = {
309
+ // the shorthand the device caught: `async *name()` in a class body.
310
+ 'cursor.ts': `
311
+ export class Cursor {
312
+ async *rows() {
313
+ yield await Promise.resolve(1)
314
+ }
315
+ }
316
+ `,
317
+ 'stream.ts': `
318
+ export async function* stream() {
319
+ yield 1
320
+ }
321
+ `,
322
+ 'drain.ts': `
323
+ export async function drain(src) {
324
+ for await (const x of src) {
325
+ console.log(x)
326
+ }
327
+ }
328
+ `,
329
+ }
330
+
331
+ for (const [filename, sourceCode] of Object.entries(sources)) {
332
+ const result = await transform(
333
+ {},
334
+ '/project',
335
+ filename,
336
+ Buffer.from(sourceCode, 'utf8'),
337
+ { dev: true, platform: 'ios', type: 'module' }
338
+ )
339
+
340
+ assertZeroBabelCalls()
341
+
342
+ const code = result.output[0].data.code
343
+ // hermes rejects all three of these at parse time, so none may survive.
344
+ expect(code, filename).not.toMatch(/\basync\s+function\s*\*/)
345
+ expect(code, filename).not.toMatch(/\basync\s*\*\s*[\w$[]/)
346
+ expect(code, filename).not.toMatch(/\bfor\s+await\s*\(/)
347
+ }
348
+ })
349
+
350
+ it('handles JSON files without Babel', async () => {
351
+ const jsonSource = JSON.stringify({ name: 'my-package', version: '1.0.0' })
352
+ const result = await transform(
353
+ {},
354
+ '/project',
355
+ 'package.json',
356
+ Buffer.from(jsonSource, 'utf8'),
357
+ { dev: true, platform: 'ios', type: 'module' }
358
+ )
359
+
360
+ assertZeroBabelCalls()
361
+ expect(result.dependencies).toEqual([])
362
+ expect(result.output).toHaveLength(1)
363
+ expect(result.output[0].type).toBe('js/module')
364
+ expect(result.output[0].data.code).toContain('module.exports = {"name":"my-package","version":"1.0.0"}')
365
+ expect(result.output[0].data.code).toContain('__d(function (global, require')
366
+ })
367
+
368
+ it('handles Asset files without Babel', async () => {
369
+ const result = await transform(
370
+ {
371
+ assetRegistryPath: 'react-native/Libraries/Image/AssetRegistry',
372
+ publicPath: '/assets',
373
+ },
374
+ '/project',
375
+ 'logo.png',
376
+ Buffer.from('fake-png-binary-data', 'utf8'),
377
+ { dev: true, platform: 'ios', type: 'asset' }
378
+ )
379
+
380
+ assertZeroBabelCalls()
381
+ expect(result.output[0].type).toBe('js/module/asset')
382
+ expect(result.output[0].data.code).toContain('registerAsset(')
383
+ expect(result.output[0].data.code).toContain('__d(function')
384
+
385
+ const depNames = result.dependencies.map((d) => d.name)
386
+ expect(depNames).toContain('react-native/Libraries/Image/AssetRegistry')
387
+ })
388
+
389
+ it('handles Flow files by stripping types without Babel', async () => {
390
+ const flowSource = `
391
+ // @flow
392
+ function add(a: number, b: number): number {
393
+ return a + b;
394
+ }
395
+ export default add;
396
+ `
397
+
398
+ const result = await transform(
399
+ {},
400
+ '/project',
401
+ 'math.js',
402
+ Buffer.from(flowSource, 'utf8'),
403
+ { dev: false, platform: 'ios', type: 'module' }
404
+ )
405
+
406
+ assertZeroBabelCalls()
407
+ expect(result.output[0].data.code).not.toContain(': number')
408
+ expect(result.output[0].data.code).toContain('function add(a, b)')
409
+ })
410
+
411
+ it('generates a deterministic cache key without Babel', () => {
412
+ const key1 = getCacheKey({ globalPrefix: '__one_', minifierPath: 'terser' }, { projectRoot: '/app' })
413
+ const key2 = getCacheKey({ globalPrefix: '__one_', minifierPath: 'terser' }, { projectRoot: '/app' })
414
+ const key3 = getCacheKey({ globalPrefix: '__other_' }, { projectRoot: '/app' })
415
+
416
+ assertZeroBabelCalls()
417
+ expect(key1).toBe(key2)
418
+ expect(key1).not.toBe(key3)
419
+ expect(typeof key1).toBe('string')
420
+ expect(key1.length).toBe(64) // SHA-256 hex
421
+ })
422
+
423
+ it('correctly sets transformerPath in buildMetroConfigInputFromViteConfig when active', async () => {
424
+ const mockViteConfig = { root: process.cwd() } as any
425
+
426
+ // transformerPath is read by metro at the top level of the config. asserting
427
+ // it under `transformer` passes while metro silently keeps its own worker,
428
+ // so both the presence and the nesting are checked here.
429
+
430
+ // Default: the no-babel worker, with nothing configured.
431
+ delete process.env.ONE_METRO_NATIVE_TRANSFORMS
432
+ const configDefault = await buildMetroConfigInputFromViteConfig(mockViteConfig, {})
433
+ expect(configDefault.defaultConfig.transformerPath).toContain('metroNativeWorker')
434
+ expect(configDefault.defaultConfig.transformer.transformerPath).toBeUndefined()
435
+
436
+ // Opting back into babel via metroPluginOptions leaves expo's default
437
+ // worker in the key, which is itself proof that top level is where metro
438
+ // reads it from.
439
+ const configOptOff = await buildMetroConfigInputFromViteConfig(mockViteConfig, {
440
+ nativeTransforms: false,
441
+ })
442
+ expect(configOptOff.defaultConfig.transformerPath).not.toContain('metroNativeWorker')
443
+
444
+ // Opting back into babel for one run via the environment variable
445
+ process.env.ONE_METRO_NATIVE_TRANSFORMS = '0'
446
+ const configEnvOff = await buildMetroConfigInputFromViteConfig(mockViteConfig, {})
447
+ expect(configEnvOff.defaultConfig.transformerPath).not.toContain('metroNativeWorker')
448
+
449
+ // and the env var wins over an option that asked for babel
450
+ process.env.ONE_METRO_NATIVE_TRANSFORMS = '1'
451
+ const configEnvOn = await buildMetroConfigInputFromViteConfig(mockViteConfig, {
452
+ nativeTransforms: false,
453
+ })
454
+ expect(configEnvOn.defaultConfig.transformerPath).toContain('metroNativeWorker')
455
+ delete process.env.ONE_METRO_NATIVE_TRANSFORMS
456
+ })
457
+
458
+ it('transforms and executes a real 2-module Metro bundle through metro-runtime require polyfill', async () => {
459
+ // Probe 1 verification:
460
+ // Module 2 (dep.js): exports a value
461
+ // Module 1 (a.js): requires dep.js using Metro dependency ABI and exports computed result
462
+ const depSource = 'exports.value = 42;'
463
+ const aSource = 'const dep = require("./dep"); module.exports = { doubled: dep.value * 2 };'
464
+
465
+ // Transform dep.js (Module 2)
466
+ const depRes = await transform(
467
+ {},
468
+ '/project',
469
+ 'dep.js',
470
+ Buffer.from(depSource, 'utf8'),
471
+ {
472
+ dev: false,
473
+ platform: 'ios',
474
+ type: 'module',
475
+ moduleId: 2,
476
+ dependencyIds: [],
477
+ } as any
478
+ )
479
+
480
+ // Transform a.js (Module 1) which depends on dep.js (dependency slot 0 -> moduleId 2)
481
+ const aRes = await transform(
482
+ {},
483
+ '/project',
484
+ 'a.js',
485
+ Buffer.from(aSource, 'utf8'),
486
+ {
487
+ dev: false,
488
+ platform: 'ios',
489
+ type: 'module',
490
+ moduleId: 1,
491
+ dependencyIds: [2],
492
+ } as any
493
+ )
494
+
495
+ assertZeroBabelCalls()
496
+
497
+ // Verify dependency extraction
498
+ expect(aRes.dependencies).toHaveLength(1)
499
+ expect(aRes.dependencies[0].name).toBe('./dep')
500
+
501
+ // Verify dependency call rewriting to Metro ABI
502
+ const aEmittedCode = aRes.output[0].data.code
503
+ expect(aEmittedCode).toContain('require(_dependencyMap[0], "./dep")')
504
+
505
+ // Execute through REAL Metro runtime polyfill
506
+ const polyfillPath = require.resolve('metro-runtime/src/polyfills/require.js')
507
+ const polyfillCode = fs.readFileSync(polyfillPath, 'utf8')
508
+
509
+ const contextObj = {
510
+ __DEV__: false,
511
+ __METRO_GLOBAL_PREFIX__: '',
512
+ console: console,
513
+ } as any
514
+ contextObj.global = contextObj
515
+ vm.createContext(contextObj)
516
+ vm.runInContext(polyfillCode, contextObj)
517
+
518
+ // Register module 2 and module 1 in the Metro runtime
519
+ vm.runInContext(depRes.output[0].data.code, contextObj)
520
+ vm.runInContext(aEmittedCode, contextObj)
521
+
522
+ // Execute Module 1 via Metro's require entrypoint
523
+ const executedExports = vm.runInContext('global.__r(1)', contextObj)
524
+ expect(executedExports).toEqual({ doubled: 84 })
525
+ })
526
+
527
+ it('transforms and executes a 2-module Metro bundle with ESM imports in dev mode', async () => {
528
+ const depSource = 'export const greeting = "Hello Metro Native";'
529
+ const aSource = 'import { greeting } from "./dep"; export const message = greeting.toUpperCase();'
530
+
531
+ // Transform dep.js (Module 2)
532
+ const depRes = await transform(
533
+ {},
534
+ '/project',
535
+ 'dep.js',
536
+ Buffer.from(depSource, 'utf8'),
537
+ {
538
+ dev: true,
539
+ platform: 'ios',
540
+ type: 'module',
541
+ moduleId: 2,
542
+ dependencyIds: [],
543
+ } as any
544
+ )
545
+
546
+ // Transform a.js (Module 1) depending on dep.js (dependency slot 0 -> moduleId 2)
547
+ const aRes = await transform(
548
+ {},
549
+ '/project',
550
+ 'a.js',
551
+ Buffer.from(aSource, 'utf8'),
552
+ {
553
+ dev: true,
554
+ platform: 'ios',
555
+ type: 'module',
556
+ moduleId: 1,
557
+ dependencyIds: [2],
558
+ } as any
559
+ )
560
+
561
+ assertZeroBabelCalls()
562
+
563
+ const polyfillPath = require.resolve('metro-runtime/src/polyfills/require.js')
564
+ const polyfillCode = fs.readFileSync(polyfillPath, 'utf8')
565
+
566
+ const contextObj = {
567
+ __DEV__: true,
568
+ __METRO_GLOBAL_PREFIX__: '',
569
+ console: console,
570
+ } as any
571
+ contextObj.global = contextObj
572
+ vm.createContext(contextObj)
573
+ vm.runInContext(polyfillCode, contextObj)
574
+
575
+ vm.runInContext(depRes.output[0].data.code, contextObj)
576
+ vm.runInContext(aRes.output[0].data.code, contextObj)
577
+
578
+ const executedExports = vm.runInContext('global.__r(1)', contextObj)
579
+ expect(executedExports.message).toBe('HELLO METRO NATIVE')
580
+ })
581
+
582
+ it('respects lexical scope shadowing for require calls (Astra probe)', async () => {
583
+ // Exact probe from Astra:
584
+ // function f(require){return require("./local")}; module.exports=f(x=>x);
585
+ // require is shadowed by parameter: must NOT extract ./local, and must NOT rewrite require
586
+ const input = 'function f(require){return require("./local")}; module.exports=f(x=>x);'
587
+
588
+ const res = await transform(
589
+ {},
590
+ '/project',
591
+ 'shadowed.js',
592
+ Buffer.from(input, 'utf8'),
593
+ {
594
+ dev: false,
595
+ platform: 'ios',
596
+ type: 'module',
597
+ moduleId: 1,
598
+ dependencyIds: [],
599
+ } as any
600
+ )
601
+
602
+ assertZeroBabelCalls()
603
+
604
+ // 1. Dependency must NOT be extracted
605
+ expect(res.dependencies).toHaveLength(0)
606
+
607
+ // 2. Emitted code must NOT rewrite local require to _dependencyMap
608
+ const emitted = res.output[0].data.code
609
+ expect(emitted).not.toContain('_dependencyMap[0]')
610
+ expect(emitted).toMatch(/require\d*\("\.\/local"\)/)
611
+
612
+ // 3. Bundled execution through Metro runtime returns the string without missing-module error
613
+ const polyfillPath = require.resolve('metro-runtime/src/polyfills/require.js')
614
+ const polyfillCode = fs.readFileSync(polyfillPath, 'utf8')
615
+
616
+ const contextObj = {
617
+ __DEV__: false,
618
+ __METRO_GLOBAL_PREFIX__: '',
619
+ console: console,
620
+ } as any
621
+ contextObj.global = contextObj
622
+ vm.createContext(contextObj)
623
+ vm.runInContext(polyfillCode, contextObj)
624
+
625
+ vm.runInContext(emitted, contextObj)
626
+ const result = vm.runInContext('global.__r(1)', contextObj)
627
+ expect(result).toBe('./local')
628
+ })
629
+
630
+ it('preserves distinct indexed identity for duplicate and mixed sync/async dependencies', async () => {
631
+ const code = `
632
+ const a = require('./dep');
633
+ const b = require('./dep');
634
+ async function load() {
635
+ const c = await import('./dep');
636
+ }
637
+ `
638
+ const res = await transform(
639
+ {},
640
+ '/project',
641
+ 'index.js',
642
+ Buffer.from(code, 'utf8'),
643
+ { dev: false, platform: 'ios', type: 'module' }
644
+ )
645
+
646
+ assertZeroBabelCalls()
647
+
648
+ // Key format separates sync require from async import
649
+ const depNames = res.dependencies.map((d) => d.name)
650
+ expect(depNames).toContain('./dep')
651
+
652
+ const syncDep = res.dependencies.find((d) => d.name === './dep' && d.data.asyncType === null)!
653
+ const asyncDep = res.dependencies.find((d) => d.name === './dep' && d.data.asyncType === 'async')!
654
+ expect(syncDep).toBeDefined()
655
+ expect(asyncDep).toBeDefined()
656
+ expect(syncDep.data.index).not.toEqual(asyncDep.data.index)
657
+
658
+ // Rewritten code points both sync require calls to the sync dependency slot index
659
+ const emitted = res.output[0].data.code
660
+ const syncIndex = syncDep.data.index
661
+ expect(emitted).toContain(`require(_dependencyMap[${syncIndex}], "./dep")`)
662
+ })
663
+
664
+ it('conforms to Metro map ABI with raw mapping segment tuples and accurate symbolication', async () => {
665
+ const sourceCode = `
666
+ export function add(a: number, b: number): number {
667
+ return a + b;
668
+ }
669
+ `
670
+
671
+ const res = await transform(
672
+ {},
673
+ '/project',
674
+ 'math.ts',
675
+ Buffer.from(sourceCode, 'utf8'),
676
+ { dev: false, platform: 'ios', type: 'module' }
677
+ )
678
+
679
+ assertZeroBabelCalls()
680
+
681
+ const map = res.output[0].data.map
682
+ expect(Array.isArray(map)).toBe(true)
683
+ expect(map.length).toBeGreaterThan(0)
684
+
685
+ // Verify Metro Raw Mapping Segment Tuples:
686
+ // Every element must be an array tuple [line, column, origLine?, origCol?, name?]
687
+ // No SourceMapV3 objects embedded
688
+ for (const tuple of map) {
689
+ expect(Array.isArray(tuple)).toBe(true)
690
+ expect(typeof tuple[0]).toBe('number') // generated line (1-based)
691
+ expect(typeof tuple[1]).toBe('number') // generated column (0-based)
692
+ expect(tuple).not.toHaveProperty('version')
693
+ expect(tuple).not.toHaveProperty('mappings')
694
+ }
695
+
696
+ // Verify with Metro's own fromRawMappings and Consumer
697
+ const msm = await import('metro-source-map')
698
+ const fullMap = (msm.fromRawMappings as any)([
699
+ {
700
+ code: res.output[0].data.code,
701
+ source: 'math.ts',
702
+ map,
703
+ },
704
+ ]).toMap()
705
+
706
+ const consumer = new msm.Consumer(fullMap)
707
+ // Find generated line for function add
708
+ const lines = res.output[0].data.code.split('\n')
709
+ const addLineIndex = lines.findIndex((l) => l.includes('function add'))
710
+ expect(addLineIndex).toBeGreaterThan(0)
711
+
712
+ const pos = consumer.originalPositionFor({
713
+ line: (addLineIndex + 1) as any,
714
+ column: 16 as any,
715
+ })
716
+
717
+ expect(pos.line).toBe(2)
718
+ })
719
+
720
+ it('models JS hoisting in ScopeTracker for functions and vars (Astra probe)', async () => {
721
+ // Exact probe from Astra:
722
+ // function f(){const x=require("./local"); function require(x){return x}; return x} module.exports=f();
723
+ // function require is hoisted to f's function scope, so require("./local") is a local call and needs no module.
724
+ const input = 'function f(){const x=require("./local"); function require(x){return x}; return x} module.exports=f();'
725
+
726
+ const res = await transform(
727
+ {},
728
+ '/project',
729
+ 'hoisted.js',
730
+ Buffer.from(input, 'utf8'),
731
+ {
732
+ dev: false,
733
+ platform: 'ios',
734
+ type: 'module',
735
+ moduleId: 1,
736
+ dependencyIds: [],
737
+ } as any
738
+ )
739
+
740
+ assertZeroBabelCalls()
741
+
742
+ // 1. Dependency must NOT be extracted
743
+ expect(res.dependencies).toHaveLength(0)
744
+
745
+ // 2. Emitted code must NOT rewrite require("./local") to _dependencyMap
746
+ const emitted = res.output[0].data.code
747
+ expect(emitted).not.toContain('_dependencyMap[0]')
748
+ expect(emitted).toMatch(/require\d*\("\.\/local"\)/)
749
+
750
+ // 3. Bundled execution through Metro runtime returns "./local" without missing-module error
751
+ const polyfillPath = require.resolve('metro-runtime/src/polyfills/require.js')
752
+ const polyfillCode = fs.readFileSync(polyfillPath, 'utf8')
753
+
754
+ const contextObj = {
755
+ __DEV__: false,
756
+ __METRO_GLOBAL_PREFIX__: '',
757
+ console: console,
758
+ } as any
759
+ contextObj.global = contextObj
760
+ vm.createContext(contextObj)
761
+ vm.runInContext(polyfillCode, contextObj)
762
+
763
+ vm.runInContext(emitted, contextObj)
764
+ const result = vm.runInContext('global.__r(1)', contextObj)
765
+ expect(result).toBe('./local')
766
+ })
767
+
768
+ it('rewrites dynamic import to Metro asyncRequire and executes promise resolution in dev and release', async () => {
769
+ // Exact probe from Astra:
770
+ // transform of module.exports = () => import("./dep");
771
+ // Emitted __d wrapper must rewrite to require(asyncRequire)(_dependencyMap[dep], _dependencyMap.paths, "./dep")
772
+ // and resolve promise through real Metro runtime.
773
+ const depSource = 'exports.message = "hello dynamic";'
774
+ const mainSource = 'module.exports = () => import("./dep");'
775
+
776
+ const depRes = await transform(
777
+ {},
778
+ '/project',
779
+ 'dep.js',
780
+ Buffer.from(depSource, 'utf8'),
781
+ {
782
+ dev: true,
783
+ platform: 'ios',
784
+ type: 'module',
785
+ moduleId: 2,
786
+ dependencyIds: [],
787
+ } as any
788
+ )
789
+
790
+ const mainRes = await transform(
791
+ {},
792
+ '/project',
793
+ 'main.js',
794
+ Buffer.from(mainSource, 'utf8'),
795
+ {
796
+ dev: true,
797
+ platform: 'ios',
798
+ type: 'module',
799
+ moduleId: 0,
800
+ dependencyIds: [2, 1], // slot 0 -> module 2 (dep), slot 1 -> module 1 (asyncRequire)
801
+ } as any
802
+ )
803
+
804
+ assertZeroBabelCalls()
805
+
806
+ // 1. Dependencies include ./dep (async) and asyncRequire (sync)
807
+ const depNames = mainRes.dependencies.map((d) => d.name)
808
+ expect(depNames).toContain('./dep')
809
+ expect(depNames).toContain('metro-runtime/src/modules/asyncRequire')
810
+
811
+ const dynDep = mainRes.dependencies.find((d) => d.name === './dep')!
812
+ expect(dynDep.data.asyncType).toBe('async')
813
+
814
+ // 2. Emitted code must rewrite import("./dep") to asyncRequire call
815
+ const emittedMain = mainRes.output[0].data.code
816
+ expect(emittedMain).not.toContain('import("./dep")')
817
+ expect(emittedMain).toContain(
818
+ 'require(_dependencyMap[1], "metro-runtime/src/modules/asyncRequire")(_dependencyMap[0], _dependencyMap.paths, "./dep")'
819
+ )
820
+
821
+ // 3. Test Metro VM runtime execution in DEV and RELEASE modes
822
+ const polyfillPath = require.resolve('metro-runtime/src/polyfills/require.js')
823
+ const polyfillCode = fs.readFileSync(polyfillPath, 'utf8')
824
+ const asyncRequirePath = require.resolve('metro-runtime/src/modules/asyncRequire.js')
825
+ const asyncRequireSource = fs.readFileSync(asyncRequirePath, 'utf8')
826
+ const wrappedAsyncRequire = wrapModule(asyncRequireSource, { moduleId: 1, dependencyIds: [] })
827
+
828
+ for (const isDev of [true, false]) {
829
+ const contextObj = {
830
+ __DEV__: isDev,
831
+ __METRO_GLOBAL_PREFIX__: '',
832
+ console: console,
833
+ } as any
834
+ contextObj.global = contextObj
835
+ vm.createContext(contextObj)
836
+ vm.runInContext(polyfillCode, contextObj)
837
+
838
+ // Register asyncRequire (1), dep (2), and main (0)
839
+ vm.runInContext(wrappedAsyncRequire, contextObj)
840
+ vm.runInContext(depRes.output[0].data.code, contextObj)
841
+ vm.runInContext(emittedMain, contextObj)
842
+
843
+ const mainFn = vm.runInContext('global.__r(0)', contextObj)
844
+ expect(typeof mainFn).toBe('function')
845
+
846
+ const promise = mainFn()
847
+ expect(promise instanceof Promise || (promise && typeof promise.then === 'function')).toBe(true)
848
+
849
+ const resolved = await promise
850
+ expect(resolved.message).toBe('hello dynamic')
851
+ expect(resolved.default.message).toBe('hello dynamic')
852
+ }
853
+ })
854
+
855
+ it('preserves accurate sourcemap for tokens after rewritten require calls on the same line', async () => {
856
+ // Exact probe from Astra:
857
+ // rewriteDependencyCalls returns { code, map }
858
+ // Trace a source location AFTER a rewritten require on the same generated line
859
+ const sourceCode = 'const a = require("./dep"), b = 42;'
860
+
861
+ // Transform with module wrapping (standard mode)
862
+ const resWrapped = await transform(
863
+ {},
864
+ '/project',
865
+ 'app.js',
866
+ Buffer.from(sourceCode, 'utf8'),
867
+ {
868
+ dev: false,
869
+ platform: 'ios',
870
+ type: 'module',
871
+ } as any
872
+ )
873
+
874
+ assertZeroBabelCalls()
875
+
876
+ const msm = await import('metro-source-map')
877
+ const fullMapWrapped = (msm.fromRawMappings as any)([
878
+ {
879
+ code: resWrapped.output[0].data.code,
880
+ source: 'app.js',
881
+ map: resWrapped.output[0].data.map,
882
+ },
883
+ ]).toMap()
884
+
885
+ const consumerWrapped = new msm.Consumer(fullMapWrapped)
886
+ const linesWrapped = resWrapped.output[0].data.code.split('\n')
887
+ const lineIndexWrapped = linesWrapped.findIndex((l) => l.includes('b = 42'))
888
+ expect(lineIndexWrapped).toBeGreaterThanOrEqual(0)
889
+
890
+ const generatedColWrapped = linesWrapped[lineIndexWrapped].indexOf('b = 42')
891
+ expect(generatedColWrapped).toBeGreaterThan(0)
892
+
893
+ const posWrapped = consumerWrapped.originalPositionFor({
894
+ line: (lineIndexWrapped + 1) as any,
895
+ column: generatedColWrapped as any,
896
+ })
897
+
898
+ // Original position of 'b = 42' in sourceCode: line 1, column 28
899
+ expect(posWrapped.line).toBe(1)
900
+ expect(posWrapped.column).toBe(28)
901
+ expect(sourceCode.slice(Number(posWrapped.column))).toContain('b = 42;')
902
+
903
+ // Also test unwrapped mode: unstable_disableModuleWrapping: true
904
+ const resUnwrapped = await transform(
905
+ { unstable_disableModuleWrapping: true },
906
+ '/project',
907
+ 'app.js',
908
+ Buffer.from(sourceCode, 'utf8'),
909
+ {
910
+ dev: false,
911
+ platform: 'ios',
912
+ type: 'module',
913
+ } as any
914
+ )
915
+
916
+ const fullMapUnwrapped = (msm.fromRawMappings as any)([
917
+ {
918
+ code: resUnwrapped.output[0].data.code,
919
+ source: 'app.js',
920
+ map: resUnwrapped.output[0].data.map,
921
+ },
922
+ ]).toMap()
923
+
924
+ const consumerUnwrapped = new msm.Consumer(fullMapUnwrapped)
925
+ const linesUnwrapped = resUnwrapped.output[0].data.code.split('\n')
926
+ const lineIndexUnwrapped = linesUnwrapped.findIndex((l) => l.includes('b = 42'))
927
+ expect(lineIndexUnwrapped).toBeGreaterThanOrEqual(0)
928
+
929
+ const generatedColUnwrapped = linesUnwrapped[lineIndexUnwrapped].indexOf('b = 42')
930
+ const posUnwrapped = consumerUnwrapped.originalPositionFor({
931
+ line: (lineIndexUnwrapped + 1) as any,
932
+ column: generatedColUnwrapped as any,
933
+ })
934
+
935
+ expect(posUnwrapped.line).toBe(1)
936
+ expect(posUnwrapped.column).toBe(28)
937
+ })
938
+
939
+ it('routes a require.context whose directory is held in a variable through the dependency map', async () => {
940
+ // one's router entry reads its route root into a binding and passes that to
941
+ // require.context. if the call is not rewritten it survives into the bundle
942
+ // and reaches metro's fallbackRequireContext, which throws at startup.
943
+ const sourceCode = `
944
+ const routeRoot = './app'
945
+ export const ctx = require.context(routeRoot, true, /\\.tsx$/)
946
+ `
947
+
948
+ const result = await transform({}, '/project', 'entry.js', Buffer.from(sourceCode, 'utf8'), {
949
+ dev: true,
950
+ platform: 'ios',
951
+ type: 'module',
952
+ })
953
+
954
+ const contextDep = result.dependencies.find((d) => d.data.contextParams != null)
955
+ expect(contextDep?.name).toBe('./app')
956
+
957
+ const calls: any[] = []
958
+ let ctx: any
959
+ const sandbox: any = {
960
+ __d: (factory: any) => {
961
+ const module = { exports: {} as any }
962
+ const req: any = (...args: any[]) => {
963
+ calls.push(args)
964
+ return { keys: () => [] }
965
+ }
966
+ // reaching this means the call was left as a runtime require.context
967
+ req.context = () => {
968
+ throw new Error('fallbackRequireContext reached')
969
+ }
970
+ factory({}, req, () => ({}), () => ({}), module, module.exports, ['./app'])
971
+ ctx = module.exports.ctx
972
+ },
973
+ }
974
+
975
+ vm.runInNewContext(result.output[0].data.code, sandbox)
976
+
977
+ expect(ctx).toBeDefined()
978
+ expect(calls[0][1]).toBe('./app')
979
+ })
980
+
981
+ it('gives loop bodies per-iteration bindings that survive a runtime without them', async () => {
982
+ const sourceCode = `
983
+ export function collect() {
984
+ const out = []
985
+ for (let i = 0; i < 2; i++) out.push(() => i)
986
+ for (const label of ['a', 'b']) out.push(() => label)
987
+ for (const key in { x: 1, y: 2 }) out.push(() => key)
988
+ return out.map((f) => f())
989
+ }
990
+ `
991
+
992
+ const result = await transform({}, '/project', 'loops.ts', Buffer.from(sourceCode, 'utf8'), {
993
+ dev: true,
994
+ platform: 'ios',
995
+ type: 'module',
996
+ })
997
+
998
+ const code = result.output[0].data.code
999
+
1000
+ // Hermes emits one CreateFunctionEnvironment before a loop rather than one
1001
+ // per iteration, so a `let` loop head behaves exactly like `var` there.
1002
+ // Rewriting the heads that way reproduces Hermes on a runtime that is
1003
+ // otherwise spec-correct, which is what makes this assertion meaningful:
1004
+ // the untransformed source returns 2,2,b,b,y,y under it.
1005
+ const asHermesWouldSeeIt = code.replace(
1006
+ /\bfor\s*\(\s*(let|const)\b/g,
1007
+ 'for (var'
1008
+ )
1009
+
1010
+ let collect: any
1011
+ const sandbox: any = {
1012
+ __d: (factory: any) => {
1013
+ const module = { exports: {} as any }
1014
+ factory({}, () => ({}), () => ({}), () => ({}), module, module.exports, [])
1015
+ collect = module.exports.collect
1016
+ },
1017
+ }
1018
+ vm.runInNewContext(asHermesWouldSeeIt, sandbox)
1019
+
1020
+ expect(collect()).toEqual([0, 1, 'a', 'b', 'x', 'y'])
1021
+ })
1022
+ })
1023
+
1024
+ describe('one native transform ports', () => {
1025
+ it('inlines process.env.ONE_SERVER_URL so a native bundle can find its loaders', () => {
1026
+ // without this a native prod bundle keeps a `process.env` read that has
1027
+ // nothing behind it at runtime, and every loader fetch goes to undefined.
1028
+ process.env.ONE_SERVER_URL = 'https://example.test'
1029
+ try {
1030
+ const out = applyInlineEnvVars(
1031
+ 'export const url = process.env.ONE_SERVER_URL;',
1032
+ 'loader.ts',
1033
+ false
1034
+ )
1035
+ expect(out).toBe('export const url = "https://example.test";')
1036
+ } finally {
1037
+ process.env.ONE_SERVER_URL = undefined
1038
+ }
1039
+ })
1040
+
1041
+ it('leaves ONE_SERVER_URL alone when it is only a string', () => {
1042
+ const src = 'export const key = "process.env.ONE_SERVER_URL";'
1043
+ expect(applyInlineEnvVars(src, 'a.ts', false)).toBe(src)
1044
+ })
1045
+
1046
+ it('inlines import.meta.env reads, which oxc otherwise lowers to an empty object', () => {
1047
+ // oxc's CJS lowering emits `var import_meta = {}`, so an untouched
1048
+ // `import.meta.env.X` silently reads undefined in every native bundle.
1049
+ const env = { DEV: false, VITE_POSTHOG_API_KEY: 'pk_live', TAMAGUI_TARGET: 'native' }
1050
+ const out = applyInlineEnvVars(
1051
+ `export const key = import.meta.env.VITE_POSTHOG_API_KEY;
1052
+ export const target = import.meta.env?.TAMAGUI_TARGET;
1053
+ export const missing = import.meta.env.NOT_SET;
1054
+ export const all = { ...import.meta.env };`,
1055
+ 'env.ts',
1056
+ true,
1057
+ env
1058
+ )
1059
+ expect(out).toContain('export const key = "pk_live";')
1060
+ expect(out).toContain('export const target = "native";')
1061
+ expect(out).toContain('export const missing = undefined;')
1062
+ expect(out).toContain('"VITE_POSTHOG_API_KEY":"pk_live"')
1063
+ expect(out).not.toContain('import.meta')
1064
+ })
1065
+
1066
+ it('inlines process.env keys the vite env map defines', () => {
1067
+ const out = applyInlineEnvVars(
1068
+ 'export const t = process.env.TAMAGUI_TARGET; export const u = process.env.UNKNOWN_THING;',
1069
+ 'env.ts',
1070
+ true,
1071
+ { TAMAGUI_TARGET: 'native' }
1072
+ )
1073
+ expect(out).toContain('export const t = "native";')
1074
+ // a key the map does not define is left for the runtime, not guessed at.
1075
+ expect(out).toContain('process.env.UNKNOWN_THING')
1076
+ })
1077
+
1078
+ it('never rewrites an env read in an assignment target', () => {
1079
+ const src = 'process.env.VITE_ENVIRONMENT = "ssr";'
1080
+ expect(applyInlineEnvVars(src, 'a.ts', true, { VITE_ENVIRONMENT: 'ios' })).toBe(src)
1081
+ })
1082
+
1083
+ it('folds branches the inlining made constant so metro never resolves dead imports', () => {
1084
+ const out = applyInlineEnvVars(
1085
+ `if (import.meta.env.SSR) { require('./server-only-thing') } else { load(import.meta.env.API) }`,
1086
+ 'branch.ts',
1087
+ true,
1088
+ { SSR: false, API: 'https://api.test' }
1089
+ )
1090
+ expect(out).not.toContain('server-only-thing')
1091
+ // the surviving branch still gets its own inlining, which a fold that
1092
+ // copied original source would have thrown away.
1093
+ expect(out).toContain('load("https://api.test")')
1094
+ })
1095
+
1096
+ it('folds a ternary and keeps a branch it cannot resolve', () => {
1097
+ expect(
1098
+ applyInlineEnvVars('const a = import.meta.env.DEV ? x : y;', 'a.ts', true, { DEV: false })
1099
+ ).toContain('const a = y;')
1100
+ const dynamic = 'const a = flag ? x : y;'
1101
+ expect(applyInlineEnvVars(dynamic, 'a.ts', true, { DEV: false })).toBe(dynamic)
1102
+ })
1103
+
1104
+ it('runs the user native transforms that stand in for their babel plugins', async () => {
1105
+ // takeout's OTA plugin rewrites a bare `__HOT_UPDATER_BUNDLE_ID` that is
1106
+ // otherwise undeclared, so without a way to run it the bundle throws a
1107
+ // ReferenceError at module load. this is that way.
1108
+ const result = await transform(
1109
+ {},
1110
+ import.meta.dirname,
1111
+ 'ota.ts',
1112
+ Buffer.from(
1113
+ 'export const id = __EXAMPLE_BUILD_ID; export const from = __EXAMPLE_FILENAME;',
1114
+ 'utf8'
1115
+ ),
1116
+ {
1117
+ dev: true,
1118
+ platform: 'ios',
1119
+ type: 'module',
1120
+ customTransformOptions: {
1121
+ vite: {
1122
+ nativeTransformModules: ['./__fixtures__/exampleNativeTransform.cjs'],
1123
+ },
1124
+ },
1125
+ } as any
1126
+ )
1127
+
1128
+ assertZeroBabelCalls()
1129
+ expect(result.output[0].data.code).toContain('"ios-build"')
1130
+ expect(result.output[0].data.code).not.toContain('__EXAMPLE_BUILD_ID')
1131
+ // the transform is handed an absolute path, not metro's relative one
1132
+ expect(result.output[0].data.code).toContain(
1133
+ JSON.stringify(path.join(import.meta.dirname, 'ota.ts'))
1134
+ )
1135
+ })
1136
+
1137
+ it('refuses to build when a user babel plugin has no native port', () => {
1138
+ // this worker runs no babel, so an unrecognized plugin does nothing at all.
1139
+ // takeout adds `hot-updater/babel-plugin` for OTA; dropping it silently
1140
+ // would ship a build whose updates never apply.
1141
+ const withPlugins = (plugins: any[]) =>
1142
+ ({ customTransformOptions: { vite: { babelConfig: { plugins } } } }) as any
1143
+
1144
+ expect(() =>
1145
+ assertNoUnportedBabelPlugins(
1146
+ withPlugins([
1147
+ ['@vxrn/vite-plugin-metro/babel-plugins/import-meta-env-plugin', { env: {} }],
1148
+ 'one/babel-plugin-environment-guard',
1149
+ 'hot-updater/babel-plugin',
1150
+ ])
1151
+ )
1152
+ ).toThrow(/hot-updater\/babel-plugin/)
1153
+
1154
+ expect(() =>
1155
+ assertNoUnportedBabelPlugins(
1156
+ withPlugins([
1157
+ ['@vxrn/vite-plugin-metro/babel-plugins/import-meta-env-plugin', { env: {} }],
1158
+ 'one/babel-plugin-environment-guard',
1159
+ ['one/babel-plugin-remove-server-code', { routerRoot: 'app' }],
1160
+ ['babel-plugin-module-resolver', { alias: {} }],
1161
+ ['one/babel-plugin-one-router-metro', {}],
1162
+ 'one/babel-plugin-inline-one-server-url',
1163
+ 'babel-plugin-react-compiler',
1164
+ 'react-native-reanimated/plugin',
1165
+ ])
1166
+ )
1167
+ ).not.toThrow()
1168
+ })
1169
+
1170
+ it('turns a server-only import into a throw and drops native-only', () => {
1171
+ const out = applyEnvironmentGuard(
1172
+ `import 'native-only'\nimport 'server-only'\nexport const a = 1`,
1173
+ 'route.ts'
1174
+ )
1175
+ expect(out).not.toContain('native-only')
1176
+ expect(out).toContain('server-only cannot be imported in a native environment')
1177
+ // the throw has to replace the import as a STATEMENT. an expression left in
1178
+ // its place is a syntax error, which would fail the whole bundle to parse.
1179
+ const parsed = parseSync('route.ts', out, { lang: 'ts' })
1180
+ expect(parsed.errors).toEqual([])
1181
+ expect(parsed.program.body.map((n: any) => n.type)).toContain('ThrowStatement')
1182
+ })
1183
+
1184
+ it('reads the router root off one\'s remove-server-code plugin entry', () => {
1185
+ const options: any = {
1186
+ customTransformOptions: {
1187
+ vite: {
1188
+ babelConfig: {
1189
+ plugins: [['one/babel-plugin-remove-server-code', { routerRoot: 'src/app' }]],
1190
+ },
1191
+ },
1192
+ },
1193
+ }
1194
+ expect(getRemoveServerCodeRouterRoot(options)).toBe('src/app')
1195
+ // absent means one did not ask for it, which must not be read as 'app'
1196
+ expect(getRemoveServerCodeRouterRoot({ customTransformOptions: {} } as any)).toBe(
1197
+ undefined
1198
+ )
1199
+ })
1200
+ })