vxrn 1.26.0 → 1.26.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.
Files changed (42) hide show
  1. package/dist/plugins/reactNativeDevServer.mjs +123 -42
  2. package/dist/plugins/reactNativeDevServer.mjs.map +1 -1
  3. package/dist/plugins/reactNativeDevServer.native.js +209 -44
  4. package/dist/plugins/reactNativeDevServer.native.js.map +1 -1
  5. package/dist/rn-commands/bundle/buildBundle.mjs +29 -10
  6. package/dist/rn-commands/bundle/buildBundle.mjs.map +1 -1
  7. package/dist/rn-commands/bundle/buildBundle.native.js +29 -10
  8. package/dist/rn-commands/bundle/buildBundle.native.js.map +1 -1
  9. package/dist/utils/createNativeDevEngine.mjs +220 -83
  10. package/dist/utils/createNativeDevEngine.mjs.map +1 -1
  11. package/dist/utils/createNativeDevEngine.native.js +257 -84
  12. package/dist/utils/createNativeDevEngine.native.js.map +1 -1
  13. package/dist/utils/createNativeDevEngine.test.mjs +285 -23
  14. package/dist/utils/createNativeDevEngine.test.mjs.map +1 -1
  15. package/dist/utils/createNativeDevEngine.test.native.js +319 -23
  16. package/dist/utils/createNativeDevEngine.test.native.js.map +1 -1
  17. package/dist/utils/patches.test.mjs.map +1 -1
  18. package/dist/utils/patches.test.native.js.map +1 -1
  19. package/dist/utils/symbolicateNativeStack.mjs +66 -0
  20. package/dist/utils/symbolicateNativeStack.mjs.map +1 -0
  21. package/dist/utils/symbolicateNativeStack.native.js +70 -0
  22. package/dist/utils/symbolicateNativeStack.native.js.map +1 -0
  23. package/dist/utils/symbolicateNativeStack.test.mjs +96 -0
  24. package/dist/utils/symbolicateNativeStack.test.mjs.map +1 -0
  25. package/dist/utils/symbolicateNativeStack.test.native.js +96 -0
  26. package/dist/utils/symbolicateNativeStack.test.native.js.map +1 -0
  27. package/package.json +12 -11
  28. package/src/plugins/reactNativeDevServer.ts +162 -50
  29. package/src/rn-commands/bundle/buildBundle.ts +28 -9
  30. package/src/utils/createNativeDevEngine.test.ts +581 -193
  31. package/src/utils/createNativeDevEngine.ts +434 -115
  32. package/src/utils/patches.test.ts +1 -4
  33. package/src/utils/symbolicateNativeStack.test.ts +78 -0
  34. package/src/utils/symbolicateNativeStack.ts +129 -0
  35. package/types/plugins/reactNativeDevServer.d.ts.map +1 -1
  36. package/types/rn-commands/bundle/buildBundle.d.ts.map +1 -1
  37. package/types/utils/createNativeDevEngine.d.ts +23 -4
  38. package/types/utils/createNativeDevEngine.d.ts.map +1 -1
  39. package/types/utils/symbolicateNativeStack.d.ts +46 -0
  40. package/types/utils/symbolicateNativeStack.d.ts.map +1 -0
  41. package/types/utils/symbolicateNativeStack.test.d.ts +2 -0
  42. package/types/utils/symbolicateNativeStack.test.d.ts.map +1 -0
@@ -1,10 +1,12 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, realpathSync } from 'node:fs'
2
2
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { createContext, runInContext, runInNewContext } from 'node:vm'
6
- import { rolldown, type RolldownOutput } from 'rolldown'
6
+ import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'
7
+ import { rolldown, type Plugin, type RolldownOutput } from 'rolldown'
7
8
  import { dev } from 'rolldown/experimental'
9
+ import { normalizePath } from 'vite'
8
10
  import { describe, expect, it, vi } from 'vitest'
9
11
  import { getNativePrelude } from '../runtime/native-prelude'
10
12
  import { workletImportsPlugin } from '../plugins/workletImportsPlugin'
@@ -20,10 +22,104 @@ import {
20
22
  hmrClientNoopPlugin,
21
23
  nativeAnimatedGuardPlugin,
22
24
  normalizeNativeCommonJSInterop,
25
+ postProcessNativeBundle,
23
26
  vxrnCompilerPlugin,
24
27
  wrapNativeBundleModuleScope,
28
+ type NativePluginContext,
25
29
  } from './createNativeDevEngine'
26
30
 
31
+ describe.each(['ios', 'android'] as const)('native plugin adapters on %s', (platform) => {
32
+ it.each(['dev', 'build'] as const)(
33
+ 'binds configured plugins to the %s bundle',
34
+ async (mode) => {
35
+ const root = await mkdtemp(join(tmpdir(), 'vxrn-native-plugin-adapter-'))
36
+ await writeFile(
37
+ join(root, 'entry.js'),
38
+ `globalThis.nativePluginResult = [__GLOBAL_PLUGIN__, __DIRECT_PLUGIN__, 'plain-plugin']`
39
+ )
40
+ const factories = ['global', 'direct'].map((name) =>
41
+ vi.fn(
42
+ (context: NativePluginContext): Plugin => ({
43
+ name: `${name}-native`,
44
+ transform(code, id) {
45
+ if (id.endsWith('/entry.js')) {
46
+ return code.replace(
47
+ `__${name.toUpperCase()}_PLUGIN__`,
48
+ JSON.stringify({ name, ...context })
49
+ )
50
+ }
51
+ },
52
+ })
53
+ )
54
+ )
55
+ const plugins = factories.map(
56
+ (vxrnNative, index): Plugin => ({
57
+ name: `adapter-${index}`,
58
+ api: { vxrnNative },
59
+ })
60
+ )
61
+ const previousPlugins = globalThis.__vxrnAddNativePlugins
62
+ globalThis.__vxrnAddNativePlugins = [plugins[0]]
63
+ const configuredPlugins: Plugin[] = [
64
+ plugins[1],
65
+ {
66
+ name: 'plain-native-plugin',
67
+ transform(code, id) {
68
+ if (id.endsWith('/__virtual-native-entry.tsx')) return `import './entry.js'`
69
+ if (id.endsWith('/entry.js')) return code.replace('plain-plugin', 'plain')
70
+ },
71
+ },
72
+ ]
73
+ let native: Awaited<ReturnType<typeof createNativeDevEngine>> | undefined
74
+ try {
75
+ let code: string
76
+ if (mode === 'dev') {
77
+ native = await createNativeDevEngine({
78
+ root,
79
+ platform,
80
+ port: 0,
81
+ plugins: configuredPlugins,
82
+ })
83
+ code = (await native.getBundle()).code
84
+ } else {
85
+ code = (
86
+ await buildNativeBundle({
87
+ root,
88
+ platform,
89
+ entryFile: 'entry.js',
90
+ plugins: configuredPlugins,
91
+ })
92
+ ).code
93
+ }
94
+ const context = {
95
+ console,
96
+ setTimeout,
97
+ clearTimeout,
98
+ __GLOBAL_PLUGIN__: 'unadapted',
99
+ __DIRECT_PLUGIN__: 'unadapted',
100
+ }
101
+ runInNewContext(code, context)
102
+ expect(Reflect.get(context, 'nativePluginResult')).toEqual([
103
+ { name: 'global', root, platform, dev: mode === 'dev' },
104
+ { name: 'direct', root, platform, dev: mode === 'dev' },
105
+ 'plain',
106
+ ])
107
+ for (const factory of factories) {
108
+ expect(factory).toHaveBeenCalledExactlyOnceWith({
109
+ root,
110
+ platform,
111
+ dev: mode === 'dev',
112
+ })
113
+ }
114
+ } finally {
115
+ await native?.close()
116
+ globalThis.__vxrnAddNativePlugins = previousPlugins
117
+ await rm(root, { recursive: true, force: true })
118
+ }
119
+ }
120
+ )
121
+ })
122
+
27
123
  const nativeTransformProbe = `
28
124
  export const transformProbe = () => {
29
125
  'worklet'
@@ -128,120 +224,155 @@ globalThis.result = (function(parentArgument) {
128
224
  }
129
225
  )
130
226
 
131
- describe.each(['shared', 'rolldown', 'metro'])('React Compiler worklets through %s', (pipeline) => {
132
- it.each([
133
- ['block', 'useDerivedValue(() => { return value + 8 }, [value])', 18],
134
- ['expression', 'useDerivedValue(() => value + 8, [value])', 18],
135
- ['object', 'useDerivedValue(() => ({ lift: value + 8 }), [value])', { lift: 18 }],
136
- ['nested', 'useDerivedValue(() => runOnUI(() => value + 8), [value])', 18],
137
- ['gesture', 'useDerivedValue(() => Gesture.Pan().onUpdate(() => value + 8), [value])', 18],
138
- ])('preserves %s callbacks on the UI runtime and memoizes stable inputs', async (_name, callback, expected) => {
139
- const compiler = await import('@vxrn/compiler')
140
- const { parseSync } = await import('oxc-parser')
141
- const { default: MagicString } = await import('magic-string')
142
- const { TraceMap, originalPositionFor } = await import('@jridgewell/trace-mapping')
143
- const projectRoot = await createWorkletsProject(false)
144
- const sourceMaps = process.env.VXRN_ENABLE_SOURCE_MAP
145
- process.env.VXRN_ENABLE_SOURCE_MAP = '1'
146
- compiler.configureVXRNCompilerPlugin({
147
- enableCompiler: true,
148
- enableReanimated: true,
149
- enableNativeWorklets: true,
150
- })
151
- try {
152
- const id = join(projectRoot, 'useProbe.ts')
153
- const source = `export function useProbe(value) {\n return ${callback};\n}\n`
154
- await writeFile(id, source)
155
- let result: any
156
- if (pipeline === 'shared') {
157
- const plugins = await compiler.createVXRNCompilerPlugin()
158
- const plugin = plugins.find((p: any) => p.name === 'one:compiler') as any
159
- await plugin.configResolved({ root: projectRoot, build: {} })
160
- const hook = plugin.transform.handler || plugin.transform
161
- result = await hook.call({ environment: { name: 'ios' } }, source, id)
162
- } else if (pipeline === 'metro') {
163
- const { transform } = await import('@vxrn/vite-plugin-metro/metroNativeWorker')
164
- const output = await transform({}, projectRoot, id, Buffer.from(source), {
165
- dev: false,
166
- platform: 'ios',
167
- type: 'module',
168
- customTransformOptions: { reactCompiler: true, worklets: true },
227
+ describe.each(['shared', 'rolldown', 'metro'])(
228
+ 'React Compiler worklets through %s',
229
+ (pipeline) => {
230
+ it.each([
231
+ ['block', 'useDerivedValue(() => { return value + 8 }, [value])', 18],
232
+ ['expression', 'useDerivedValue(() => value + 8, [value])', 18],
233
+ ['object', 'useDerivedValue(() => ({ lift: value + 8 }), [value])', { lift: 18 }],
234
+ ['nested', 'useDerivedValue(() => runOnUI(() => value + 8), [value])', 18],
235
+ [
236
+ 'gesture',
237
+ 'useDerivedValue(() => Gesture.Pan().onUpdate(() => value + 8), [value])',
238
+ 18,
239
+ ],
240
+ ])(
241
+ 'preserves %s callbacks on the UI runtime and memoizes stable inputs',
242
+ async (_name, callback, expected) => {
243
+ const compiler = await import('@vxrn/compiler')
244
+ const { parseSync } = await import('oxc-parser')
245
+ const { default: MagicString } = await import('magic-string')
246
+ const { TraceMap, originalPositionFor } =
247
+ await import('@jridgewell/trace-mapping')
248
+ const projectRoot = await createWorkletsProject(false)
249
+ const sourceMaps = process.env.VXRN_ENABLE_SOURCE_MAP
250
+ process.env.VXRN_ENABLE_SOURCE_MAP = '1'
251
+ compiler.configureVXRNCompilerPlugin({
252
+ enableCompiler: true,
253
+ enableReanimated: true,
254
+ enableNativeWorklets: true,
169
255
  })
170
- const data = output.output[0].data
171
- const msm = await import('metro-source-map')
172
- result = {
173
- code: data.code,
174
- map: (msm.fromRawMappings as any)([{ code: data.code, path: id, source, map: data.map }]).toMap(),
175
- }
176
- } else {
177
- const plugin = vxrnCompilerPlugin('ios', false, projectRoot, true)
178
- result = await Reflect.apply(plugin.transform as Function, undefined, [source, id])
179
- }
180
- const runnable = new MagicString(result.code)
181
- for (const node of parseSync('probe.js', result.code).program.body) {
182
- if (node.type === 'ImportDeclaration') runnable.remove(node.start, node.end)
183
- if (node.type === 'ExportNamedDeclaration' && node.declaration) {
184
- runnable.remove(node.start, node.declaration.start)
185
- }
186
- }
187
- let cache: any[] | undefined
188
- let cacheCalls = 0
189
- const memo = (size: number) => {
190
- cacheCalls++
191
- return cache ??= Array(size).fill(Symbol.for('react.memo_cache_sentinel'))
192
- }
193
- const module = { exports: {} as any }
194
- const probe = new Function('_c', 'useDerivedValue', 'runOnUI', 'Gesture', '__d', 'module',
195
- `${runnable}\nreturn ${pipeline === 'metro' ? 'module.exports.useProbe' : 'useProbe'}`
196
- )(
197
- memo,
198
- (fn: any) => fn,
199
- (fn: any) => fn,
200
- { Pan: () => ({ onUpdate: (fn: any) => fn }) },
201
- (factory: any) => factory(globalThis, () => ({ c: memo }), () => {}, () => {}, module, module.exports, []),
202
- module
203
- )
204
- const first = probe(10)
205
- expect(first.__initData?.code).toBeTypeOf('string')
206
- for (const onUI of [false, true]) {
207
- let fn = first
208
- let value: any
209
- for (let depth = 0; depth < 2; depth++) {
210
- expect(fn.__initData?.code).toBeTypeOf('string')
211
- value = onUI
212
- ? new Function('runOnUI', `return (${fn.__initData.code})`)((fn: any) => fn)
213
- .call({ __closure: fn.__closure })
214
- : fn()
215
- if (typeof value !== 'function') break
216
- fn = value
256
+ try {
257
+ const id = join(projectRoot, 'useProbe.ts')
258
+ const source = `export function useProbe(value) {\n return ${callback};\n}\n`
259
+ await writeFile(id, source)
260
+ let result: any
261
+ if (pipeline === 'shared') {
262
+ const plugins = await compiler.createVXRNCompilerPlugin()
263
+ const plugin = plugins.find((p: any) => p.name === 'one:compiler') as any
264
+ await plugin.configResolved({ root: projectRoot, build: {} })
265
+ const hook = plugin.transform.handler || plugin.transform
266
+ result = await hook.call({ environment: { name: 'ios' } }, source, id)
267
+ } else if (pipeline === 'metro') {
268
+ const { transform } =
269
+ await import('@vxrn/vite-plugin-metro/metroNativeWorker')
270
+ const output = await transform({}, projectRoot, id, Buffer.from(source), {
271
+ dev: false,
272
+ platform: 'ios',
273
+ type: 'module',
274
+ customTransformOptions: { reactCompiler: true, worklets: true },
275
+ })
276
+ const data = output.output[0].data
277
+ const msm = await import('metro-source-map')
278
+ result = {
279
+ code: data.code,
280
+ map: (msm.fromRawMappings as any)([
281
+ { code: data.code, path: id, source, map: data.map },
282
+ ]).toMap(),
283
+ }
284
+ } else {
285
+ const plugin = vxrnCompilerPlugin('ios', false, projectRoot, true)
286
+ result = await Reflect.apply(plugin.transform as Function, undefined, [
287
+ source,
288
+ id,
289
+ ])
290
+ }
291
+ const runnable = new MagicString(result.code)
292
+ for (const node of parseSync('probe.js', result.code).program.body) {
293
+ if (node.type === 'ImportDeclaration') runnable.remove(node.start, node.end)
294
+ if (node.type === 'ExportNamedDeclaration' && node.declaration) {
295
+ runnable.remove(node.start, node.declaration.start)
296
+ }
297
+ }
298
+ let cache: any[] | undefined
299
+ let cacheCalls = 0
300
+ const memo = (size: number) => {
301
+ cacheCalls++
302
+ return (cache ??= Array(size).fill(Symbol.for('react.memo_cache_sentinel')))
303
+ }
304
+ const module = { exports: {} as any }
305
+ const probe = new Function(
306
+ '_c',
307
+ 'useDerivedValue',
308
+ 'runOnUI',
309
+ 'Gesture',
310
+ '__d',
311
+ 'module',
312
+ `${runnable}\nreturn ${pipeline === 'metro' ? 'module.exports.useProbe' : 'useProbe'}`
313
+ )(
314
+ memo,
315
+ (fn: any) => fn,
316
+ (fn: any) => fn,
317
+ { Pan: () => ({ onUpdate: (fn: any) => fn }) },
318
+ (factory: any) =>
319
+ factory(
320
+ globalThis,
321
+ () => ({ c: memo }),
322
+ () => {},
323
+ () => {},
324
+ module,
325
+ module.exports,
326
+ []
327
+ ),
328
+ module
329
+ )
330
+ const first = probe(10)
331
+ expect(first.__initData?.code).toBeTypeOf('string')
332
+ for (const onUI of [false, true]) {
333
+ let fn = first
334
+ let value: any
335
+ for (let depth = 0; depth < 2; depth++) {
336
+ expect(fn.__initData?.code).toBeTypeOf('string')
337
+ value = onUI
338
+ ? new Function('runOnUI', `return (${fn.__initData.code})`)(
339
+ (fn: any) => fn
340
+ ).call({ __closure: fn.__closure })
341
+ : fn()
342
+ if (typeof value !== 'function') break
343
+ fn = value
344
+ }
345
+ expect(value).toEqual(expected)
346
+ }
347
+ expect(probe(10)).toBe(first)
348
+ expect(cacheCalls).toBe(2)
349
+ expect(probe(20)).not.toBe(first)
350
+
351
+ const lines = result.code.split('\n')
352
+ const line = lines.findIndex((text: string) =>
353
+ text.includes('function useProbe(')
354
+ )
355
+ expect(line).toBeGreaterThanOrEqual(0)
356
+ const position = originalPositionFor(new TraceMap(result.map), {
357
+ line: line + 1,
358
+ column: lines[line].indexOf('function'),
359
+ })
360
+ expect(position.source).toBe(id)
361
+ expect(position.line).toBe(1)
362
+ } finally {
363
+ if (sourceMaps === undefined) delete process.env.VXRN_ENABLE_SOURCE_MAP
364
+ else process.env.VXRN_ENABLE_SOURCE_MAP = sourceMaps
365
+ compiler.configureVXRNCompilerPlugin({
366
+ enableCompiler: false,
367
+ enableReanimated: false,
368
+ enableNativeWorklets: false,
369
+ })
370
+ await rm(projectRoot, { recursive: true, force: true })
217
371
  }
218
- expect(value).toEqual(expected)
219
372
  }
220
- expect(probe(10)).toBe(first)
221
- expect(cacheCalls).toBe(2)
222
- expect(probe(20)).not.toBe(first)
223
-
224
- const lines = result.code.split('\n')
225
- const line = lines.findIndex((text: string) => text.includes('function useProbe('))
226
- expect(line).toBeGreaterThanOrEqual(0)
227
- const position = originalPositionFor(new TraceMap(result.map), {
228
- line: line + 1,
229
- column: lines[line].indexOf('function'),
230
- })
231
- expect(position.source).toBe(id)
232
- expect(position.line).toBe(1)
233
- } finally {
234
- if (sourceMaps === undefined) delete process.env.VXRN_ENABLE_SOURCE_MAP
235
- else process.env.VXRN_ENABLE_SOURCE_MAP = sourceMaps
236
- compiler.configureVXRNCompilerPlugin({
237
- enableCompiler: false,
238
- enableReanimated: false,
239
- enableNativeWorklets: false,
240
- })
241
- await rm(projectRoot, { recursive: true, force: true })
242
- }
243
- })
244
- })
373
+ )
374
+ }
375
+ )
245
376
 
246
377
  async function createWorkletsProject(throwOnTransform = true) {
247
378
  const testRoot = await mkdtemp(join(tmpdir(), 'vxrn-native-transform-failure-'))
@@ -396,12 +527,17 @@ if (import.meta.hot) import.meta.hot.accept(() => {})
396
527
  join(root, 'production.mjs'),
397
528
  `import calculate, { reads } from 'pure-math'; globalThis.calculate = calculate; globalThis.reads = reads`
398
529
  )
530
+ // a production bundle is minified by default, so this also covers worklet
531
+ // closure serialization under mangling: the worklet body is a string
532
+ // literal the minifier leaves alone, and __closure carries its captures
533
+ // by property name, which mangling does not rewrite.
399
534
  const production = await buildNativeBundle({
400
535
  root,
401
536
  platform: 'android',
402
537
  entryFile: 'production.mjs',
403
538
  plugins: [workletImportsPlugin({ 'pure-math': ['default', 'reads'] })],
404
539
  })
540
+ expect(production.code).not.toContain('__esmMin')
405
541
  const productionRN: any = { console }
406
542
  runInNewContext(production.code, productionRN)
407
543
  expect(productionRN.calculate(10)).toBe(109)
@@ -409,6 +545,20 @@ if (import.meta.hot) import.meta.hot.accept(() => {})
409
545
  expect(reconstruct(productionRN.reads)()).toBe(3)
410
546
  expect(productionRN.reads()).toBe(1)
411
547
 
548
+ const unminifiedProduction = await buildNativeBundle({
549
+ root,
550
+ platform: 'android',
551
+ entryFile: 'production.mjs',
552
+ minify: false,
553
+ plugins: [workletImportsPlugin({ 'pure-math': ['default', 'reads'] })],
554
+ })
555
+ expect(unminifiedProduction.code).toContain('__esmMin')
556
+ const unminifiedRN: any = { console }
557
+ runInNewContext(unminifiedProduction.code, unminifiedRN)
558
+ expect(unminifiedRN.calculate(10)).toBe(109)
559
+ expect(reconstruct(unminifiedRN.calculate)(10)).toBe(109)
560
+ expect(reconstruct(unminifiedRN.reads)()).toBe(3)
561
+
412
562
  const sharedPlugin = workletImportsPlugin({ 'pure-math': ['default', 'reads'] })
413
563
  const parallel = await Promise.all(
414
564
  (['ios', 'android'] as const).map(async (platform) => {
@@ -512,60 +662,70 @@ if (import.meta.hot) import.meta.hot.accept(() => {})
512
662
  })
513
663
 
514
664
  describe('native Rolldown HMR runtime', () => {
515
- it.each([true, false])('keeps CommonJS default components callable through the native HMR wire path (Babel=%s)', async (babel) => {
516
- const root = await mkdtemp(join(tmpdir(), 'vxrn-native-hmr-interop-'))
517
- const entry = join(root, 'entry.mjs')
518
- const packageRoot = join(root, 'node_modules/component')
519
- await mkdir(packageRoot, { recursive: true })
520
- await writeFile(
521
- join(packageRoot, 'package.json'),
522
- JSON.stringify({ main: './component(view).js' })
523
- )
524
- await writeFile(
525
- join(packageRoot, 'component(view).js'),
526
- babel
527
- ? `Object.defineProperty(exports, '__esModule', { value: true }); exports.default = function Component() { return 42 }`
528
- : `module.exports = function Component() { return 42 }`
529
- )
530
- const source = (version: number) => `
665
+ it.each([true, false])(
666
+ 'keeps CommonJS default components callable through the native HMR wire path (Babel=%s)',
667
+ async (babel) => {
668
+ const root = await mkdtemp(join(tmpdir(), 'vxrn-native-hmr-interop-'))
669
+ const entry = join(root, 'entry.mjs')
670
+ const packageRoot = join(root, 'node_modules/component')
671
+ await mkdir(packageRoot, { recursive: true })
672
+ await writeFile(
673
+ join(packageRoot, 'package.json'),
674
+ JSON.stringify({ main: './component(view).js' })
675
+ )
676
+ await writeFile(
677
+ join(packageRoot, 'component(view).js'),
678
+ babel
679
+ ? `Object.defineProperty(exports, '__esModule', { value: true }); exports.default = function Component() { return 42 }`
680
+ : `module.exports = function Component() { return 42 }`
681
+ )
682
+ const source = (version: number) => `
531
683
  import Component from 'component'
532
684
  globalThis.renderComponent = Component
533
685
  export const version = ${version}
534
686
  if (import.meta.hot) import.meta.hot.accept(() => {})
535
687
  `
536
- await writeFile(entry, source(1))
537
- let resolveUpdate!: (update: any) => void
538
- const updateReceived = new Promise<any>((resolve) => { resolveUpdate = resolve })
539
- const native = await createNativeDevEngine({
540
- root,
541
- port: 0,
542
- platform: 'ios',
543
- plugins: [{
544
- name: 'native-hmr-fixture-entry',
545
- transform(_code, id) {
546
- if (id.endsWith('/__virtual-native-entry.tsx')) return `import './entry.mjs'`
547
- },
548
- }],
549
- onHmrUpdate: resolveUpdate,
550
- })
551
- try {
552
- const initial = await native.getBundle()
553
- const context: any = { console, setTimeout, clearTimeout }
554
- runInNewContext(initial.code, context)
555
- expect(context.renderComponent()).toBe(42)
556
- const runtime = context.__rolldown_runtime__
557
- await native.engine.registerClient(runtime.clientId)
558
- await writeFile(entry, source(2))
559
- const update = await updateReceived
560
- expect(update.type).toBe('hmr:update')
561
- expect(update.clientId).toBe(runtime.clientId)
562
- expect(runtime.applyHmrUpdate(update.code, update.changedIds, update.seq)).toBe(true)
563
- expect(context.renderComponent()).toBe(42)
564
- } finally {
565
- await native.close()
566
- await rm(root, { recursive: true, force: true })
688
+ await writeFile(entry, source(1))
689
+ let resolveUpdate!: (update: any) => void
690
+ const updateReceived = new Promise<any>((resolve) => {
691
+ resolveUpdate = resolve
692
+ })
693
+ const native = await createNativeDevEngine({
694
+ root,
695
+ port: 0,
696
+ platform: 'ios',
697
+ plugins: [
698
+ {
699
+ name: 'native-hmr-fixture-entry',
700
+ transform(_code, id) {
701
+ if (id.endsWith('/__virtual-native-entry.tsx'))
702
+ return `import './entry.mjs'`
703
+ },
704
+ },
705
+ ],
706
+ onHmrUpdate: resolveUpdate,
707
+ })
708
+ try {
709
+ const initial = await native.getBundle()
710
+ const context: any = { console, setTimeout, clearTimeout }
711
+ runInNewContext(initial.code, context)
712
+ expect(context.renderComponent()).toBe(42)
713
+ const runtime = context.__rolldown_runtime__
714
+ await native.engine.registerClient(runtime.clientId)
715
+ await writeFile(entry, source(2))
716
+ const update = await updateReceived
717
+ expect(update.type).toBe('hmr:update')
718
+ expect(update.clientId).toBe(runtime.clientId)
719
+ expect(runtime.applyHmrUpdate(update.code, update.changedIds, update.seq)).toBe(
720
+ true
721
+ )
722
+ expect(context.renderComponent()).toBe(42)
723
+ } finally {
724
+ await native.close()
725
+ await rm(root, { recursive: true, force: true })
726
+ }
567
727
  }
568
- })
728
+ )
569
729
 
570
730
  it(
571
731
  'registers a Rolldown 1.2 client and applies a self-accepted patch',
@@ -1594,6 +1754,102 @@ export function Box() {
1594
1754
  })
1595
1755
  })
1596
1756
 
1757
+ describe('native production minification', () => {
1758
+ // a cjs dependency reached through an esm module, a top-level export, and a
1759
+ // runtime result that depends on both. minification mangles every top-level
1760
+ // name here and drops every comment, which is what the bundle's
1761
+ // post-processing reads, so a pass that runs on minified output stops
1762
+ // applying — silently for the commonjs interop, and fatally for the `export`
1763
+ // statement, which hermes cannot parse.
1764
+ async function writeMinifyFixture() {
1765
+ const root = await mkdtemp(join(tmpdir(), 'vxrn-native-minify-'))
1766
+ const dep = join(root, 'node_modules', 'legacy-color')
1767
+ await mkdir(dep, { recursive: true })
1768
+ await writeFile(
1769
+ join(dep, 'package.json'),
1770
+ JSON.stringify({ name: 'legacy-color', main: 'index.js' })
1771
+ )
1772
+ await writeFile(
1773
+ join(dep, 'index.js'),
1774
+ `function LegacyColor(value) { this.value = value }
1775
+ LegacyColor.prototype.describe = function () { return 'legacy:' + this.value }
1776
+ module.exports = LegacyColor`
1777
+ )
1778
+ await writeFile(
1779
+ join(root, 'views.js'),
1780
+ `'use strict';
1781
+ import LegacyColor from 'legacy-color'
1782
+ import { level } from './entry.js'
1783
+ export const describeHeader = () => new LegacyColor(level()).describe()`
1784
+ )
1785
+ await writeFile(
1786
+ join(root, 'entry.js'),
1787
+ `import { describeHeader } from './views.js'
1788
+ export const level = () => 'header'
1789
+ globalThis.minifyProbe = describeHeader()`
1790
+ )
1791
+ return root
1792
+ }
1793
+
1794
+ function runFixture(code: string) {
1795
+ const context: Record<string, unknown> = { console }
1796
+ runInNewContext(code, context)
1797
+ return Reflect.get(context, 'minifyProbe')
1798
+ }
1799
+
1800
+ it('minifies when asked, leaves the bundle alone when not, and both behave the same', async () => {
1801
+ const root = await writeMinifyFixture()
1802
+ try {
1803
+ const [plain, minified] = await Promise.all(
1804
+ [false, true].map((minify) =>
1805
+ buildNativeBundle({ root, platform: 'ios', entryFile: 'entry.js', minify })
1806
+ )
1807
+ )
1808
+
1809
+ // the unminified bundle keeps rolldown's generated names and layout
1810
+ expect(plain.code).toContain('__toESM')
1811
+ expect(plain.code).toContain('describeHeader')
1812
+ expect(plain.code.split('\n').length).toBeGreaterThan(40)
1813
+
1814
+ // the minified one keeps neither
1815
+ expect(minified.code).not.toContain('describeHeader')
1816
+ expect(minified.code).not.toContain('//#region')
1817
+ expect(minified.code.length).toBeLessThan(plain.code.length * 0.7)
1818
+
1819
+ // and both still run, with the same result. before the post-processing
1820
+ // moved inside the bundle, the minified one threw on its surviving
1821
+ // `export {}` statement.
1822
+ expect(runFixture(minified.code)).toBe('legacy:header')
1823
+ expect(runFixture(plain.code)).toBe('legacy:header')
1824
+ } finally {
1825
+ await rm(root, { recursive: true, force: true })
1826
+ }
1827
+ })
1828
+
1829
+ it("defaults to react native's rule: minify a production bundle, never a dev one", async () => {
1830
+ const root = await writeMinifyFixture()
1831
+ try {
1832
+ const [prod, devBundle, explicitOff] = await Promise.all([
1833
+ buildNativeBundle({ root, platform: 'ios', entryFile: 'entry.js' }),
1834
+ buildNativeBundle({ root, platform: 'ios', entryFile: 'entry.js', dev: true }),
1835
+ buildNativeBundle({
1836
+ root,
1837
+ platform: 'ios',
1838
+ entryFile: 'entry.js',
1839
+ minify: false,
1840
+ }),
1841
+ ])
1842
+
1843
+ expect(prod.code).not.toContain('describeHeader')
1844
+ expect(devBundle.code).toContain('describeHeader')
1845
+ expect(explicitOff.code).toContain('describeHeader')
1846
+ expect(prod.code.length).toBeLessThan(explicitOff.code.length)
1847
+ } finally {
1848
+ await rm(root, { recursive: true, force: true })
1849
+ }
1850
+ })
1851
+ })
1852
+
1597
1853
  describe('native production assets', () => {
1598
1854
  it('registers scale siblings and keeps monorepo assets inside assetsDest', async () => {
1599
1855
  const testRoot = await mkdtemp(join(tmpdir(), 'vxrn-native-assets-'))
@@ -1650,7 +1906,23 @@ globalThis.__nativeAssetProbe = [icon, back]
1650
1906
  assetsDest,
1651
1907
  })
1652
1908
 
1653
- expect(result.code).toMatch(/"scales":\s*\[\s*1,\s*2,\s*3\s*\]/)
1909
+ const registered: Array<{ name: string; scales: number[] }> = []
1910
+ runInNewContext(result.code, {
1911
+ console,
1912
+ require: (id: string) =>
1913
+ id === 'react-native/Libraries/Image/AssetRegistry'
1914
+ ? {
1915
+ registerAsset: (asset: { name: string; scales: number[] }) => {
1916
+ registered.push(asset)
1917
+ return asset
1918
+ },
1919
+ }
1920
+ : {},
1921
+ })
1922
+ expect(registered.map((asset) => [asset.name, asset.scales])).toEqual([
1923
+ ['icon', [1, 2, 3]],
1924
+ ['back', [1, 2]],
1925
+ ])
1654
1926
  for (const file of ['icon.png', 'icon@2x.png', 'icon@3x.png']) {
1655
1927
  expect(existsSync(join(assetsDest, 'assets/assets', file))).toBe(true)
1656
1928
  }
@@ -1835,21 +2107,23 @@ globalThis.__vxrnConditionalExportProbe = helper()
1835
2107
  })
1836
2108
 
1837
2109
  describe('native Flow sources', () => {
1838
- it.each(['', `/* ${'license text '.repeat(150)} */\n`])('strips third-party Flow following a license header (%#)', async (license) => {
1839
- const testRoot = await mkdtemp(join(tmpdir(), 'vxrn-native-flow-'))
1840
- // mirrors @react-native-masked-view/masked-view: a Flow `.js` component
1841
- // outside the react-native / @react-native scopes, which is what reached
1842
- // rolldown unstripped when its patch was skipped.
1843
- const packageRoot = join(testRoot, 'node_modules/@flowy-scope/flowy-lib')
1844
- await mkdir(packageRoot, { recursive: true })
1845
- await writeFile(
1846
- join(packageRoot, 'package.json'),
1847
- JSON.stringify({ name: '@flowy-scope/flowy-lib', main: './js/Flowy.js' })
1848
- )
1849
- await mkdir(join(packageRoot, 'js'), { recursive: true })
1850
- await writeFile(
1851
- join(packageRoot, 'js/Flowy.js'),
1852
- `${license}/**
2110
+ it.each(['', `/* ${'license text '.repeat(150)} */\n`])(
2111
+ 'strips third-party Flow following a license header (%#)',
2112
+ async (license) => {
2113
+ const testRoot = await mkdtemp(join(tmpdir(), 'vxrn-native-flow-'))
2114
+ // mirrors @react-native-masked-view/masked-view: a Flow `.js` component
2115
+ // outside the react-native / @react-native scopes, which is what reached
2116
+ // rolldown unstripped when its patch was skipped.
2117
+ const packageRoot = join(testRoot, 'node_modules/@flowy-scope/flowy-lib')
2118
+ await mkdir(packageRoot, { recursive: true })
2119
+ await writeFile(
2120
+ join(packageRoot, 'package.json'),
2121
+ JSON.stringify({ name: '@flowy-scope/flowy-lib', main: './js/Flowy.js' })
2122
+ )
2123
+ await mkdir(join(packageRoot, 'js'), { recursive: true })
2124
+ await writeFile(
2125
+ join(packageRoot, 'js/Flowy.js'),
2126
+ `${license}/**
1853
2127
  * @flow
1854
2128
  * @format
1855
2129
  */
@@ -1868,25 +2142,139 @@ export default class Flowy {
1868
2142
  }
1869
2143
  }
1870
2144
  `
1871
- )
1872
- await writeFile(
1873
- join(testRoot, 'entry.js'),
1874
- `import Flowy from '@flowy-scope/flowy-lib'
2145
+ )
2146
+ await writeFile(
2147
+ join(testRoot, 'entry.js'),
2148
+ `import Flowy from '@flowy-scope/flowy-lib'
1875
2149
  globalThis.__vxrnFlowProbe = new Flowy().measure({ value: 41 }) + 1
1876
2150
  `
1877
- )
2151
+ )
1878
2152
 
1879
- try {
1880
- const result = await buildNativeBundle({
1881
- root: testRoot,
1882
- platform: 'ios',
1883
- entryFile: 'entry.js',
1884
- })
1885
- const context = { clearTimeout, console, process: { env: {} }, setTimeout }
1886
- runInNewContext(result.code, context)
1887
- expect(Reflect.get(context, '__vxrnFlowProbe')).toBe(42)
1888
- } finally {
1889
- await rm(testRoot, { recursive: true, force: true })
2153
+ try {
2154
+ const result = await buildNativeBundle({
2155
+ root: testRoot,
2156
+ platform: 'ios',
2157
+ entryFile: 'entry.js',
2158
+ })
2159
+ const context = { clearTimeout, console, process: { env: {} }, setTimeout }
2160
+ runInNewContext(result.code, context)
2161
+ expect(Reflect.get(context, '__vxrnFlowProbe')).toBe(42)
2162
+ } finally {
2163
+ await rm(testRoot, { recursive: true, force: true })
2164
+ }
1890
2165
  }
2166
+ )
2167
+ })
2168
+
2169
+ describe('native bundle source maps', () => {
2170
+ // the generated position of a marker in the emitted bundle is exactly what a
2171
+ // device reports in a stack frame, so resolving it is the same question as
2172
+ // symbolicating a crash.
2173
+ const generatedPositionOf = (code: string, marker: string) => {
2174
+ const index = code.indexOf(marker)
2175
+ expect(index).toBeGreaterThan(-1)
2176
+ const before = code.slice(0, index)
2177
+ const lastNewline = before.lastIndexOf('\n')
2178
+ return {
2179
+ line: (before.match(/\n/g)?.length ?? 0) + 1,
2180
+ column: index - lastNewline - 1,
2181
+ }
2182
+ }
2183
+
2184
+ it(
2185
+ 'resolves a frame to the authored file and line across an app file and a package file',
2186
+ { timeout: 60_000 },
2187
+ async () => {
2188
+ // realpath: macOS hands out /var/folders temp dirs that resolve to
2189
+ // /private/var, and the map records the resolved path.
2190
+ const testRoot = realpathSync(
2191
+ await mkdtemp(join(tmpdir(), 'vxrn-native-sourcemap-'))
2192
+ )
2193
+ const packageRoot = join(testRoot, 'node_modules/probe-package')
2194
+ await mkdir(packageRoot, { recursive: true })
2195
+ await writeFile(
2196
+ join(packageRoot, 'package.json'),
2197
+ JSON.stringify({ name: 'probe-package', main: 'index.js', type: 'module' })
2198
+ )
2199
+ // a blank line directly above an `export default` is the case that used to
2200
+ // disappear during post-processing and shift every frame below it.
2201
+ await writeFile(
2202
+ join(packageRoot, 'index.js'),
2203
+ [
2204
+ `function packageThrow() {`, // 1
2205
+ ` throw new Error('__probe_package__')`, // 2
2206
+ `}`, // 3
2207
+ ``, // 4
2208
+ `export default packageThrow`, // 5
2209
+ ].join('\n')
2210
+ )
2211
+ await writeFile(
2212
+ join(testRoot, 'entry.js'),
2213
+ [
2214
+ `import packageThrow from 'probe-package'`, // 1
2215
+ ``, // 2
2216
+ `function appThrow() {`, // 3
2217
+ ` throw new Error('__probe_app__')`, // 4
2218
+ `}`, // 5
2219
+ ``, // 6
2220
+ `globalThis.__probe = [appThrow, packageThrow]`, // 7
2221
+ ].join('\n')
2222
+ )
2223
+
2224
+ try {
2225
+ const result = await buildNativeBundle({
2226
+ root: testRoot,
2227
+ platform: 'ios',
2228
+ entryFile: 'entry.js',
2229
+ dev: true,
2230
+ sourcemap: true,
2231
+ })
2232
+ expect(result.map).toBeTruthy()
2233
+ const traced = new TraceMap(JSON.parse(result.map!))
2234
+
2235
+ const app = originalPositionFor(
2236
+ traced,
2237
+ generatedPositionOf(result.code, '__probe_app__')
2238
+ )
2239
+ const pkg = originalPositionFor(
2240
+ traced,
2241
+ generatedPositionOf(result.code, '__probe_package__')
2242
+ )
2243
+
2244
+ // the authored line, which a post-processing pass that drops a line
2245
+ // silently moves
2246
+ expect(app.source?.endsWith('/entry.js')).toBe(true)
2247
+ expect(app.line).toBe(4)
2248
+ expect(pkg.source?.endsWith('/index.js')).toBe(true)
2249
+ expect(pkg.line).toBe(2)
2250
+
2251
+ // and the absolute path, which is all a crash reporter has to go on
2252
+ expect(app.source).toBe(normalizePath(join(testRoot, 'entry.js')))
2253
+ expect(pkg.source).toBe(normalizePath(join(packageRoot, 'index.js')))
2254
+ } finally {
2255
+ await rm(testRoot, { recursive: true, force: true })
2256
+ }
2257
+ }
2258
+ )
2259
+ })
2260
+
2261
+ describe('postProcessNativeBundle', () => {
2262
+ // rolldown emits the map before these passes run, so a line they remove moves
2263
+ // every frame below it away from the source it maps to.
2264
+ const lines = (code: string) => code.split('\n').length
2265
+
2266
+ it('keeps the blank line above an export default', () => {
2267
+ const input = ['var a = 1;', '', 'export default a;', 'var b = 2;', ''].join('\n')
2268
+ const out = postProcessNativeBundle(input)
2269
+ expect(lines(out)).toBe(lines(input))
2270
+ expect(out.split('\n')[2]).toBe('a;')
2271
+ })
2272
+
2273
+ it('keeps the lines of a multi-line export statement', () => {
2274
+ const input = ['var a = 1;', 'export {', ' a', '};', 'var b = 2;', ''].join('\n')
2275
+ const out = postProcessNativeBundle(input)
2276
+ expect(lines(out)).toBe(lines(input))
2277
+ expect(out).not.toContain('export {')
2278
+ expect(out.split('\n')[4]).toBe('var b = 2;')
1891
2279
  })
1892
2280
  })