vxrn 1.25.8 → 1.25.9

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 (45) hide show
  1. package/dist/config/getViteServerConfig.mjs +6 -0
  2. package/dist/config/getViteServerConfig.mjs.map +1 -1
  3. package/dist/config/getViteServerConfig.native.js +6 -0
  4. package/dist/config/getViteServerConfig.native.js.map +1 -1
  5. package/dist/config/getViteServerConfig.test.mjs +26 -0
  6. package/dist/config/getViteServerConfig.test.mjs.map +1 -0
  7. package/dist/config/getViteServerConfig.test.native.js +26 -0
  8. package/dist/config/getViteServerConfig.test.native.js.map +1 -0
  9. package/dist/exports/dev.mjs +26 -1
  10. package/dist/exports/dev.mjs.map +1 -1
  11. package/dist/exports/dev.native.js +26 -1
  12. package/dist/exports/dev.native.js.map +1 -1
  13. package/dist/plugins/reactNativeDevServer.mjs +80 -7
  14. package/dist/plugins/reactNativeDevServer.mjs.map +1 -1
  15. package/dist/plugins/reactNativeDevServer.native.js +80 -7
  16. package/dist/plugins/reactNativeDevServer.native.js.map +1 -1
  17. package/dist/runtime/native-prelude.mjs +7 -0
  18. package/dist/runtime/native-prelude.mjs.map +1 -1
  19. package/dist/runtime/native-prelude.native.js +7 -0
  20. package/dist/runtime/native-prelude.native.js.map +1 -1
  21. package/dist/utils/createNativeDevEngine.mjs +302 -197
  22. package/dist/utils/createNativeDevEngine.mjs.map +1 -1
  23. package/dist/utils/createNativeDevEngine.native.js +410 -221
  24. package/dist/utils/createNativeDevEngine.native.js.map +1 -1
  25. package/dist/utils/createNativeDevEngine.test.mjs +536 -6
  26. package/dist/utils/createNativeDevEngine.test.mjs.map +1 -1
  27. package/dist/utils/createNativeDevEngine.test.native.js +559 -6
  28. package/dist/utils/createNativeDevEngine.test.native.js.map +1 -1
  29. package/package.json +11 -11
  30. package/src/config/getViteServerConfig.test.ts +29 -0
  31. package/src/config/getViteServerConfig.ts +11 -0
  32. package/src/exports/dev.ts +34 -3
  33. package/src/plugins/reactNativeDevServer.ts +89 -12
  34. package/src/runtime/native-prelude.ts +7 -0
  35. package/src/utils/createNativeDevEngine.test.ts +682 -5
  36. package/src/utils/createNativeDevEngine.ts +516 -229
  37. package/types/config/getViteServerConfig.d.ts.map +1 -1
  38. package/types/config/getViteServerConfig.test.d.ts +2 -0
  39. package/types/config/getViteServerConfig.test.d.ts.map +1 -0
  40. package/types/exports/dev.d.ts.map +1 -1
  41. package/types/plugins/reactNativeDevServer.d.ts +1 -0
  42. package/types/plugins/reactNativeDevServer.d.ts.map +1 -1
  43. package/types/runtime/native-prelude.d.ts.map +1 -1
  44. package/types/utils/createNativeDevEngine.d.ts +40 -2
  45. package/types/utils/createNativeDevEngine.d.ts.map +1 -1
@@ -6,12 +6,20 @@
6
6
  * https://github.com/leegeunhyeok/rollipop
7
7
  */
8
8
 
9
+ import { createHash } from 'node:crypto'
9
10
  import { basename, dirname, extname, join, relative, resolve } from 'node:path'
10
- import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
11
+ import {
12
+ copyFileSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ realpathSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ } from 'node:fs'
11
19
  import { pathToFileURL } from 'node:url'
12
20
  import type { InputOptions, OutputOptions, Plugin, RolldownOutput } from 'rolldown'
13
21
  import type { DevEngine } from 'rolldown/experimental'
14
- import { normalizePath } from 'vite'
22
+ import { loadEnv as loadViteEnv, normalizePath } from 'vite'
15
23
  import { DEFAULT_ASSET_EXTS } from '../constants/defaults'
16
24
  import { getNativePrelude } from '../runtime/native-prelude'
17
25
 
@@ -28,18 +36,21 @@ const FLOW_FILE_PATTERN = /node_modules[\\/](?:react-native|@react-native)[\\/].
28
36
  const HERMES_CLASS_TRANSFORMS = [
29
37
  'transform-classes',
30
38
  'transform-parameters',
39
+ 'transform-block-scoping',
31
40
  'transform-class-properties',
32
41
  'transform-class-static-block',
33
42
  'transform-private-methods',
34
43
  'transform-private-property-in-object',
35
44
  ] as const
36
45
 
37
- // prod-only: needed for hermesc bytecode AOT compilation, not the dev interpreter
38
- const HERMES_PROD_TRANSFORMS = ['transform-async-to-generator'] as const
46
+ // Hermes V1 rejects async generators in both the dev interpreter and AOT
47
+ // compilation. Keep the async lowering identical across modes: a dev bundle
48
+ // that parses in Rolldown but redboxes in Hermes is not a usable build.
49
+ const HERMES_ASYNC_TRANSFORMS = ['transform-async-to-generator'] as const
39
50
 
40
51
  /** SWC `env.include` for Hermes-compatible downleveling; see HERMES_CLASS_TRANSFORMS. */
41
52
  export function getHermesSWCIncludes(dev: boolean): string[] {
42
- return [...HERMES_CLASS_TRANSFORMS, ...(dev ? [] : HERMES_PROD_TRANSFORMS)]
53
+ return [...HERMES_CLASS_TRANSFORMS, ...HERMES_ASYNC_TRANSFORMS]
43
54
  }
44
55
 
45
56
  interface NativeDevEngineOptions {
@@ -66,6 +77,7 @@ export type NativeHmrUpdate =
66
77
  interface NativeDevEngineResult {
67
78
  engine: DevEngine
68
79
  getBundle: () => Promise<{ code: string }>
80
+ getAsset: (pathname: string, hash?: string) => NativeDevAsset | undefined
69
81
  close: () => Promise<void>
70
82
  }
71
83
 
@@ -84,7 +96,11 @@ function getResolveExtensions(platform: 'ios' | 'android'): string[] {
84
96
  function getNativeResolveConfig(platform: 'ios' | 'android') {
85
97
  return {
86
98
  extensions: getResolveExtensions(platform),
87
- conditionNames: ['react-native', 'import', 'require', 'default'],
99
+ // Rolldown supplies `import`, `require`, and `default` contextually. Adding
100
+ // both here makes a CommonJS require eligible for an export map's ESM
101
+ // `import` target (for example @babel/runtime), which turns callable CJS
102
+ // helpers into namespace objects at runtime.
103
+ conditionNames: ['react-native'],
88
104
  mainFields: ['react-native', 'module', 'main'],
89
105
  }
90
106
  }
@@ -120,37 +136,19 @@ export function getNativeTransformConfig(
120
136
  }
121
137
  })()
122
138
 
123
- // load .env files for VITE_* variables (mirrors what Vite does)
124
- const envDefines = (() => {
125
- const defines: Record<string, string> = {}
126
- try {
127
- const mode = dev ? 'development' : 'production'
128
- // load .env, .env.local, .env.[mode], .env.[mode].local (same order as Vite)
129
- for (const envFile of [
130
- '.env',
131
- '.env.local',
132
- `.env.${mode}`,
133
- `.env.${mode}.local`,
134
- ]) {
135
- const envPath = join(root, envFile)
136
- if (!existsSync(envPath)) continue
137
- const content = readFileSync(envPath, 'utf8')
138
- for (const line of content.split('\n')) {
139
- const match = line.match(/^\s*(VITE_\w+)\s*=\s*(.*)$/)
140
- if (match) {
141
- const [, key, rawVal] = match
142
- const val = rawVal.replace(/^['"]|['"]$/g, '').trim()
143
- defines[`import.meta.env.${key}`] = JSON.stringify(val)
144
- defines[`process.env.${key}`] = JSON.stringify(val)
145
- }
146
- }
147
- }
148
- } catch {}
149
- return defines
150
- })()
151
-
152
139
  const mode = dev ? 'development' : 'production'
153
140
 
141
+ // Match One's Vite client contract: load public values from process.env and
142
+ // the mode-specific env files, with shell values taking precedence. Native
143
+ // apps commonly use Expo's EXPO_PUBLIC_ prefix; accepting only VITE_ here
144
+ // made the same source silently receive `undefined` after leaving Metro.
145
+ const publicEnv = loadViteEnv(mode, root, ['VITE_', 'EXPO_PUBLIC_'])
146
+ const envDefines: Record<string, string> = {}
147
+ for (const [key, value] of Object.entries(publicEnv)) {
148
+ envDefines[`import.meta.env.${key}`] = JSON.stringify(value)
149
+ envDefines[`process.env.${key}`] = JSON.stringify(value)
150
+ }
151
+
154
152
  // build the full import.meta.env object for when it's used as a whole (e.g. JSON.stringify(import.meta.env))
155
153
  const envObject: Record<string, any> = {
156
154
  MODE: mode,
@@ -163,7 +161,7 @@ export function getNativeTransformConfig(
163
161
  TAMAGUI_TARGET: 'native',
164
162
  TAMAGUI_ENVIRONMENT: platform,
165
163
  }
166
- // add VITE_* from .env files
164
+ // add public values from the shell and mode-specific env files
167
165
  for (const [key, val] of Object.entries(envDefines)) {
168
166
  const match = key.match(/^import\.meta\.env\.(.+)$/)
169
167
  if (match) {
@@ -218,7 +216,9 @@ function getNativePlugins(
218
216
  platform: string,
219
217
  viteImportGlobPlugin: any,
220
218
  dev: boolean,
221
- assetsDest?: string
219
+ assetsDest?: string,
220
+ onAsset?: (asset: NativeAssetData) => void,
221
+ sourceMaps = false
222
222
  ): Plugin[] {
223
223
  return [
224
224
  // plugins provided by One (clientTreeShakePlugin for loader removal, etc.)
@@ -240,7 +240,7 @@ function getNativePlugins(
240
240
  // flowStripPlugin so react-native's Flow `.js` specs reach codegen with their
241
241
  // type argument intact. stripping Flow first would erase it (which is why the
242
242
  // codegen "didn't run for <Component>" warning fired).
243
- vxrnCompilerPlugin(platform, dev, root),
243
+ vxrnCompilerPlugin(platform, dev, root, sourceMaps),
244
244
  // strip Flow from any react-native / @react-native `.js` the compiler didn't
245
245
  // handle, the guaranteed safety net before rolldown's oxc core parse (which
246
246
  // can't parse Flow). now downstream of the compiler, so codegen sees the types.
@@ -248,9 +248,9 @@ function getNativePlugins(
248
248
  // guard undefined native methods in NativeAnimatedHelper
249
249
  nativeAnimatedGuardPlugin(),
250
250
  // handle asset imports (.png, .jpg, .ttf, etc.)
251
- assetPlugin({ root, platform, assetsDest }),
251
+ assetPlugin({ root, platform, assetsDest, onAsset }),
252
252
  // hermes compat: transform class properties and private fields
253
- hermesCompatSWCPlugin(dev),
253
+ hermesCompatSWCPlugin(dev, sourceMaps),
254
254
  ]
255
255
  }
256
256
 
@@ -271,7 +271,25 @@ function getNativeOutputOptions(prelude: string, sourcemap: boolean): OutputOpti
271
271
  * - VXRN_REACT_19 → handled by define in getNativeTransformConfig
272
272
  * - DevSettings stripping → stripDevSettingsPlugin
273
273
  */
274
+ export function normalizeNativeCommonJSInterop(code: string): string {
275
+ // Rolldown can mark ESM default imports from internal CommonJS modules as
276
+ // Node-mode conversions in both dev and production. Babel CommonJS packages
277
+ // expose their actual default behind `exports.default`; Node mode instead
278
+ // returns the whole exports object and React receives `{ default: Component }`.
279
+ return code.replace(
280
+ /(\b__toESM(?:\$\d+)?\(\s*require[\w$]*\(\)\s*),\s*1(\s*\))/g,
281
+ '$1$2'
282
+ )
283
+ }
284
+
274
285
  function postProcessNativeBundle(code: string): string {
286
+ code = normalizeNativeCommonJSInterop(code)
287
+
288
+ // Rolldown replaces import.meta.env reads but can leave a guarding
289
+ // `typeof import.meta` expression behind. Hermes rejects import.meta syntax
290
+ // even when the other side of the condition has already folded to false.
291
+ code = code.replace(/\btypeof\s+import\.meta\b/g, '"object"')
292
+
275
293
  // rolldown devMode still emits ESM export statements that hermes can't parse.
276
294
  // this is a rolldown behavior we can't configure away yet.
277
295
  code = code.replace(/^\s*export\s*\{[^}]*\}\s*;?\s*$/gm, '')
@@ -357,36 +375,51 @@ async function downlevelClassFieldsInBundle(code: string): Promise<string> {
357
375
 
358
376
  const runtimeEnd = endIdx + endMarker.length
359
377
  const runtimeSection = code.slice(startIdx, runtimeEnd)
360
-
361
- try {
362
- const swc = await import('@swc/core')
363
- const result = await swc.transform(runtimeSection, {
364
- filename: 'rolldown-runtime.js',
365
- configFile: false,
366
- swcrc: false,
367
- sourceMaps: false,
368
- inputSourceMap: false,
369
- isModule: false,
370
- env: {
371
- targets: { node: 9999 },
372
- // dev-only runtime prelude: the class set only, no prod bytecode transforms
373
- include: [...HERMES_CLASS_TRANSFORMS],
374
- },
375
- jsc: {
376
- parser: { syntax: 'ecmascript' },
377
- transform: { react: { runtime: 'preserve' } },
378
- externalHelpers: false,
379
- assumptions: {
380
- setPublicClassFields: true,
381
- privateFieldsAsProperties: true,
382
- },
378
+ const originalNewlines = runtimeSection.match(/\n/g)?.length ?? 0
379
+
380
+ const swc = await import('@swc/core')
381
+ const result = await swc.transform(runtimeSection, {
382
+ filename: 'rolldown-runtime.js',
383
+ configFile: false,
384
+ swcrc: false,
385
+ sourceMaps: false,
386
+ inputSourceMap: false,
387
+ isModule: false,
388
+ // Compact only Rolldown's generated runtime, then restore its original
389
+ // newline count below. This keeps every application module at the exact
390
+ // generated line recorded by Rolldown's source map while still lowering
391
+ // runtime class fields for Hermes.
392
+ minify: true,
393
+ env: {
394
+ targets: { node: 9999 },
395
+ // dev-only runtime prelude: the class set only, no prod bytecode transforms
396
+ include: [...HERMES_CLASS_TRANSFORMS],
397
+ },
398
+ jsc: {
399
+ parser: { syntax: 'ecmascript' },
400
+ transform: { react: { runtime: 'preserve' } },
401
+ externalHelpers: false,
402
+ assumptions: {
403
+ setPublicClassFields: true,
404
+ privateFieldsAsProperties: true,
383
405
  },
384
- })
385
- return code.slice(0, startIdx) + result.code + code.slice(runtimeEnd)
386
- } catch (err) {
387
- console.warn('[vxrn] downlevelClassFieldsInBundle failed, returning original:', err)
388
- return code
406
+ },
407
+ })
408
+ const transformedCode = result.code.trimEnd()
409
+ // SWC minification strips comments, but the dev bundle scope wrapper uses
410
+ // this generated-runtime marker as its structural boundary.
411
+ const transformed = transformedCode.includes(startMarker)
412
+ ? transformedCode
413
+ : `${startMarker}\n${transformedCode}`
414
+ const transformedNewlines = transformed.match(/\n/g)?.length ?? 0
415
+ if (transformedNewlines > originalNewlines) {
416
+ throw new Error(
417
+ '[vxrn] Hermes runtime transform added lines and would invalidate the production source map'
418
+ )
389
419
  }
420
+ const linePreservingRuntime =
421
+ transformed + '\n'.repeat(originalNewlines - transformedNewlines)
422
+ return code.slice(0, startIdx) + linePreservingRuntime + code.slice(runtimeEnd)
390
423
  }
391
424
 
392
425
  export async function createNativeDevEngine(
@@ -411,6 +444,7 @@ export async function createNativeDevEngine(
411
444
  platform,
412
445
  serverUrl: serverUrl || `http://${host}:${port}`,
413
446
  })
447
+ const assetRegistry = createNativeDevAssetRegistry()
414
448
 
415
449
  let currentBundle: { code: string } | null = null
416
450
  let firstBuildError: Error | null = null
@@ -449,7 +483,15 @@ export async function createNativeDevEngine(
449
483
 
450
484
  plugins: [
451
485
  nativeVirtualEntryPlugin(root, { dev: true }),
452
- ...getNativePlugins(root, platform, viteImportGlobPlugin, true),
486
+ ...getNativePlugins(
487
+ root,
488
+ platform,
489
+ viteImportGlobPlugin,
490
+ true,
491
+ undefined,
492
+ assetRegistry.register,
493
+ false
494
+ ),
453
495
  ...userPlugins,
454
496
  ],
455
497
  }
@@ -585,20 +627,21 @@ try {
585
627
  clearTimeout(timeoutId)
586
628
  reject(error)
587
629
  }
588
- timeoutId = setTimeout(
589
- () => {
590
- bundleResolve = null
591
- bundleReject = null
592
- bundlePromise = null
593
- reject(new Error('[vxrn] bundle build timed out after 120s'))
594
- },
595
- 120_000
596
- )
630
+ timeoutId = setTimeout(() => {
631
+ bundleResolve = null
632
+ bundleReject = null
633
+ bundlePromise = null
634
+ reject(new Error('[vxrn] bundle build timed out after 120s'))
635
+ }, 120_000)
597
636
  })
598
637
  }
599
638
  return bundlePromise
600
639
  },
601
640
 
641
+ getAsset(pathname, hash) {
642
+ return assetRegistry.resolve(pathname, hash)
643
+ },
644
+
602
645
  async close() {
603
646
  await engine.close()
604
647
  },
@@ -664,7 +707,15 @@ export async function buildNativeBundle(
664
707
  moduleTypes: { '.js': 'jsx' },
665
708
  plugins: [
666
709
  ...(entryFile ? [] : [nativeVirtualEntryPlugin(root, { dev })]),
667
- ...getNativePlugins(root, platform, viteImportGlobPlugin, dev, assetsDest),
710
+ ...getNativePlugins(
711
+ root,
712
+ platform,
713
+ viteImportGlobPlugin,
714
+ dev,
715
+ assetsDest,
716
+ undefined,
717
+ sourcemap
718
+ ),
668
719
  ...userPlugins,
669
720
  ],
670
721
  output: getNativeOutputOptions(prelude, sourcemap),
@@ -677,11 +728,10 @@ export async function buildNativeBundle(
677
728
 
678
729
  let code = postProcessNativeBundle(chunk.code)
679
730
  code = await downlevelClassFieldsInBundle(code)
680
- // note: this map is only as good as the chunk's — it describes chunk.code, and
681
- // the two post-process passes above shift lines. the per-module transforms
682
- // (vxrnCompilerPlugin, hermesCompatSWCPlugin) also return code without maps, so
683
- // module positions are post-babel/post-swc. good enough for file attribution,
684
- // not for exact line/column symbolication.
731
+ // Per-module Babel/SWC transforms return maps when requested, and the
732
+ // generated-runtime downlevel pass preserves its input line count. The
733
+ // remaining post-processing replaces syntax in place, so application frames
734
+ // retain Rolldown's generated line and compose back to original source.
685
735
  return { code, map: sourcemap ? chunk.map?.toString() : undefined }
686
736
  }
687
737
 
@@ -791,18 +841,32 @@ createApp({
791
841
  * nullthrows(NativeAnimatedModule)[methodName] which returns undefined, then
792
842
  * method(...args) throws "undefined is not a function".
793
843
  */
794
- function nativeAnimatedGuardPlugin(): Plugin {
844
+ export function nativeAnimatedGuardPlugin(): Plugin {
795
845
  return {
796
846
  name: 'vxrn:native-animated-guard',
797
847
  transform(code, id) {
798
848
  if (!id.includes('animated/NativeAnimatedHelper')) return
799
849
  const target = 'const method = nullthrows(NativeAnimatedModule)[methodName];'
800
850
  if (!code.includes(target)) return
851
+ const transformed = code.replace(
852
+ target,
853
+ `${target} if (typeof method !== 'function') return;`
854
+ )
801
855
  return {
802
- code: code.replace(
803
- target,
804
- `const method = nullthrows(NativeAnimatedModule)[methodName];\n if (typeof method !== 'function') return;`
805
- ),
856
+ code: transformed,
857
+ // this transform deliberately preserves line count. Give Rolldown a
858
+ // line-identity map so it can compose the earlier compiler map instead
859
+ // of dropping source-map coverage for this module.
860
+ map: {
861
+ version: 3,
862
+ sources: [id],
863
+ sourcesContent: [code],
864
+ names: [],
865
+ mappings: code
866
+ .split('\n')
867
+ .map((_, index) => (index === 0 ? 'AAAA' : 'AACA'))
868
+ .join(';'),
869
+ },
806
870
  }
807
871
  },
808
872
  }
@@ -925,7 +989,8 @@ function cssStubPlugin(): Plugin {
925
989
  export function vxrnCompilerPlugin(
926
990
  platform: string,
927
991
  dev: boolean,
928
- projectRoot = process.cwd()
992
+ projectRoot = process.cwd(),
993
+ sourceMaps = false
929
994
  ): Plugin {
930
995
  let compiler: typeof import('@vxrn/compiler') | null = null
931
996
 
@@ -944,51 +1009,62 @@ export function vxrnCompilerPlugin(
944
1009
 
945
1010
  const needsRefresh = isRefreshCandidate(id)
946
1011
 
947
- try {
948
- if (!compiler) compiler = await import('@vxrn/compiler')
949
-
950
- const props = {
951
- id,
952
- code,
953
- projectRoot,
954
- development: dev,
955
- environment: platform as 'ios' | 'android',
956
- reactForRNVersion: '19' as const,
957
- }
1012
+ if (!compiler) compiler = await import('@vxrn/compiler')
958
1013
 
959
- let babelOptions = compiler.getBabelOptions(props)
1014
+ const props = {
1015
+ id,
1016
+ code,
1017
+ projectRoot,
1018
+ development: dev,
1019
+ environment: platform as 'ios' | 'android',
1020
+ reactForRNVersion: '19' as const,
1021
+ }
960
1022
 
961
- if (needsRefresh) {
962
- // merge react-refresh/babel into the existing plugins (or create new options)
963
- const existingPlugins = babelOptions?.plugins || []
964
- babelOptions = {
965
- ...babelOptions,
966
- plugins: [
967
- ...existingPlugins,
968
- [
969
- 'react-refresh/babel',
970
- {
971
- skipEnvCheck: true,
972
- refreshReg: '__vxrnRefreshReg',
973
- refreshSig: '__vxrnRefreshSig',
974
- },
975
- ],
1023
+ let babelOptions = compiler.getBabelOptions(props)
1024
+
1025
+ if (needsRefresh) {
1026
+ // merge react-refresh/babel into the existing plugins (or create new options)
1027
+ const existingPlugins = babelOptions?.plugins || []
1028
+ babelOptions = {
1029
+ ...babelOptions,
1030
+ plugins: [
1031
+ ...existingPlugins,
1032
+ [
1033
+ 'react-refresh/babel',
1034
+ {
1035
+ skipEnvCheck: true,
1036
+ refreshReg: '__vxrnRefreshReg',
1037
+ refreshSig: '__vxrnRefreshSig',
1038
+ },
976
1039
  ],
977
- }
1040
+ ],
978
1041
  }
1042
+ }
979
1043
 
980
- if (!babelOptions) return
1044
+ if (!babelOptions) return
1045
+
1046
+ if (sourceMaps) {
1047
+ // Rolldown composes plugin maps in transform order. Without this map,
1048
+ // every module Babel changes is attributed only to Babel's generated
1049
+ // output, which makes production Hermes frames miss the original
1050
+ // source line even though the bundle command emits a `.map` file.
1051
+ babelOptions = {
1052
+ ...babelOptions,
1053
+ sourceMaps: true,
1054
+ sourceFileName: id,
1055
+ }
1056
+ }
981
1057
 
982
- const result = await compiler.transformBabel(id, code, babelOptions)
1058
+ const result = await compiler.transformBabel(id, code, babelOptions)
983
1059
 
984
- if (result?.code) {
985
- let out = result.code
1060
+ if (result?.code) {
1061
+ let out = result.code
986
1062
 
987
- if (needsRefresh) {
988
- // wrap with per-file $RefreshReg$ that includes the file path as unique ID
989
- // and schedule performReactRefresh() after HMR patch re-execution
990
- const escapedId = id.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
991
- out = `
1063
+ if (needsRefresh) {
1064
+ // wrap with per-file $RefreshReg$ that includes the file path as unique ID
1065
+ // and schedule performReactRefresh() after HMR patch re-execution
1066
+ const escapedId = id.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
1067
+ out = `
992
1068
  var __prevRefreshReg = globalThis.$RefreshReg$;
993
1069
  var __prevRefreshSig = globalThis.$RefreshSig$;
994
1070
  if (globalThis.__ReactRefresh) {
@@ -1006,28 +1082,16 @@ ${out}
1006
1082
  globalThis.$RefreshReg$ = __prevRefreshReg;
1007
1083
  globalThis.$RefreshSig$ = __prevRefreshSig;
1008
1084
  if (import.meta.hot) {
1009
- import.meta.hot.accept(function() {
1085
+ import.meta.hot.acceptReactRefresh(function() {
1010
1086
  if (globalThis.__ReactRefresh) {
1011
1087
  setTimeout(function() { globalThis.__ReactRefresh.performReactRefresh(); }, 30);
1012
1088
  }
1013
1089
  });
1014
1090
  }
1015
1091
  `
1016
- }
1017
-
1018
- return { code: out }
1019
- }
1020
- } catch (err: any) {
1021
- // log but don't crash — fallback to rolldown's own transform
1022
- if (dev) {
1023
- console.warn(`[vxrn:compiler] ${id}: ${err.message || err}`)
1024
- }
1025
- // if babel transform fails for a refresh candidate, still add accept boundary
1026
- if (needsRefresh) {
1027
- return {
1028
- code: code + `\nif (import.meta.hot) { import.meta.hot.accept(); }\n`,
1029
- }
1030
1092
  }
1093
+
1094
+ return { code: out, map: sourceMaps ? result.map : undefined }
1031
1095
  }
1032
1096
  },
1033
1097
  }
@@ -1044,20 +1108,16 @@ function flowStripPlugin(): Plugin {
1044
1108
  async handler(code, id) {
1045
1109
  if (!FLOW_FILE_PATTERN.test(id)) return
1046
1110
 
1047
- try {
1048
- const fft = await import('fast-flow-transform')
1049
- const result = await fft.default({
1050
- filename: id,
1051
- source: code,
1052
- sourcemap: true,
1053
- dialect: 'flow',
1054
- format: 'pretty',
1055
- })
1056
- // don't set moduleType - let rolldown's global moduleTypes config handle it
1057
- return { code: result.code, map: result.map }
1058
- } catch (err: any) {
1059
- console.warn(`[vxrn:flow-strip] ${id}: ${err.message}`)
1060
- }
1111
+ const fft = await import('fast-flow-transform')
1112
+ const result = await fft.default({
1113
+ filename: id,
1114
+ source: code,
1115
+ sourcemap: true,
1116
+ dialect: 'flow',
1117
+ format: 'pretty',
1118
+ })
1119
+ // don't set moduleType - let rolldown's global moduleTypes config handle it
1120
+ return { code: result.code, map: result.map }
1061
1121
  },
1062
1122
  },
1063
1123
  }
@@ -1071,6 +1131,7 @@ function assetPlugin(opts: {
1071
1131
  root: string
1072
1132
  platform: string
1073
1133
  assetsDest?: string
1134
+ onAsset?: (asset: NativeAssetData) => void
1074
1135
  }): Plugin {
1075
1136
  const assetRegex = new RegExp(`\\.(?:${DEFAULT_ASSET_EXTS.join('|')})$`)
1076
1137
 
@@ -1080,41 +1141,11 @@ function assetPlugin(opts: {
1080
1141
  async handler(id) {
1081
1142
  if (!assetRegex.test(id)) return
1082
1143
 
1083
- const ext = extname(id).slice(1)
1084
- const name = basename(id, `.${ext}`)
1085
- const dir = dirname(id)
1086
- const relativePath = relative(opts.root, id)
1087
- // On Windows, change backslashes to slashes to get proper URL path from file path.
1088
- const httpLocation = '/assets/' + dirname(relativePath).replace(/\\/g, '/')
1089
-
1090
- // simple asset registration (TODO: scale detection like rollipop)
1091
- const assetData = {
1092
- __packager_asset: true,
1093
- name,
1094
- type: ext,
1095
- scales: [1],
1096
- httpServerLocation: httpLocation,
1097
- fileSystemLocation: dir,
1098
- hash: '',
1099
- width: undefined as number | undefined,
1100
- height: undefined as number | undefined,
1101
- }
1102
-
1103
- // try to get image dimensions
1104
- if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(ext)) {
1105
- try {
1106
- const { imageSize } = await import('image-size')
1107
- const dims = imageSize(id)
1108
- assetData.width = dims.width
1109
- assetData.height = dims.height
1110
- } catch {}
1111
- }
1144
+ const assetData = await getNativeAssetData(id, opts.root, opts.platform)
1145
+ opts.onAsset?.(assetData)
1112
1146
 
1113
1147
  if (opts.assetsDest) {
1114
- const relativeAssetDir = dirname(relativePath).replace(/\\/g, '/')
1115
- const assetDestDir = join(opts.assetsDest, 'assets', relativeAssetDir)
1116
- mkdirSync(assetDestDir, { recursive: true })
1117
- copyFileSync(id, join(assetDestDir, `${name}.${ext}`))
1148
+ copyNativeAssetFiles(assetData, opts.assetsDest, opts.platform)
1118
1149
  }
1119
1150
 
1120
1151
  const code = `module.exports = require('react-native/Libraries/Image/AssetRegistry').registerAsset(${JSON.stringify(assetData)});`
@@ -1125,12 +1156,243 @@ function assetPlugin(opts: {
1125
1156
  }
1126
1157
  }
1127
1158
 
1159
+ type NativeAssetData = {
1160
+ __packager_asset: true
1161
+ name: string
1162
+ type: string
1163
+ scales: number[]
1164
+ files: string[]
1165
+ httpServerLocation: string
1166
+ fileSystemLocation: string
1167
+ hash: string
1168
+ width?: number
1169
+ height?: number
1170
+ }
1171
+
1172
+ export type NativeDevAsset = {
1173
+ filePath: string
1174
+ hash: string
1175
+ type: string
1176
+ }
1177
+
1178
+ export function createNativeDevAssetRegistry(): {
1179
+ register: (asset: NativeAssetData) => void
1180
+ resolve: (pathname: string, hash?: string) => NativeDevAsset | undefined
1181
+ } {
1182
+ const assets = new Map<string, NativeDevAsset>()
1183
+
1184
+ return {
1185
+ register(asset) {
1186
+ for (const [index, scale] of asset.scales.entries()) {
1187
+ const filePath = asset.files[index]
1188
+ if (!filePath) continue
1189
+ const fileName = `${asset.name}${scale === 1 ? '' : `@${scale}x`}.${asset.type}`
1190
+ assets.set(`${asset.httpServerLocation}/${fileName}`, {
1191
+ filePath,
1192
+ hash: asset.hash,
1193
+ type: asset.type,
1194
+ })
1195
+ }
1196
+ },
1197
+
1198
+ resolve(pathname, hash) {
1199
+ const asset = assets.get(pathname)
1200
+ if (!asset || (hash !== undefined && hash !== asset.hash)) return
1201
+ return asset
1202
+ },
1203
+ }
1204
+ }
1205
+
1206
+ type ParsedNativeAssetName = {
1207
+ name: string
1208
+ scale: number
1209
+ platform: string | undefined
1210
+ type: string
1211
+ }
1212
+
1213
+ const NATIVE_IMAGE_EXTS = new Set([
1214
+ 'png',
1215
+ 'jpg',
1216
+ 'jpeg',
1217
+ 'gif',
1218
+ 'webp',
1219
+ 'bmp',
1220
+ 'psd',
1221
+ 'svg',
1222
+ 'tiff',
1223
+ 'ktx',
1224
+ ])
1225
+
1226
+ function parseNativeAssetName(filePath: string): ParsedNativeAssetName | undefined {
1227
+ const type = extname(filePath).slice(1)
1228
+ if (!type) return
1229
+
1230
+ const stem = basename(filePath, `.${type}`)
1231
+ const platformMatch = stem.match(/^(.*)\.(ios|android)$/)
1232
+ const platform = platformMatch?.[2]
1233
+ const unqualifiedStem = platformMatch?.[1] ?? stem
1234
+ const scaleMatch = unqualifiedStem.match(/^(.+?)(?:@([\d.]+)x)?$/)
1235
+ if (!scaleMatch) return
1236
+
1237
+ const scale = scaleMatch[2] === undefined ? 1 : Number.parseFloat(scaleMatch[2])
1238
+ if (!Number.isFinite(scale) || scale <= 0) return
1239
+
1240
+ return {
1241
+ name: scaleMatch[1],
1242
+ scale,
1243
+ platform,
1244
+ type,
1245
+ }
1246
+ }
1247
+
1248
+ function getNativeAssetUrlDirectory(root: string, assetDirectory: string): string {
1249
+ const relativeDirectory = relative(
1250
+ realpathSync(root),
1251
+ realpathSync(assetDirectory)
1252
+ ).replace(/\\/g, '/')
1253
+ const safeDirectory = relativeDirectory
1254
+ .split('/')
1255
+ .filter((segment) => segment && segment !== '.')
1256
+ .map((segment) => (segment === '..' ? '_' : segment))
1257
+ .join('/')
1258
+ return safeDirectory ? `/assets/${safeDirectory}` : '/assets'
1259
+ }
1260
+
1261
+ export async function getNativeAssetData(
1262
+ id: string,
1263
+ root: string,
1264
+ platform: string
1265
+ ): Promise<NativeAssetData> {
1266
+ const requested = parseNativeAssetName(id)
1267
+ if (!requested) {
1268
+ throw new Error(`[vxrn] invalid native asset path: ${id}`)
1269
+ }
1270
+
1271
+ const assetDirectory = dirname(id)
1272
+ const filesByScale = new Map<number, { file: string; platformSpecific: boolean }>()
1273
+
1274
+ for (const fileName of readdirSync(assetDirectory)) {
1275
+ const candidate = parseNativeAssetName(fileName)
1276
+ if (
1277
+ !candidate ||
1278
+ candidate.name !== requested.name ||
1279
+ candidate.type !== requested.type ||
1280
+ (candidate.platform !== undefined && candidate.platform !== platform)
1281
+ ) {
1282
+ continue
1283
+ }
1284
+
1285
+ const platformSpecific = candidate.platform === platform
1286
+ const existing = filesByScale.get(candidate.scale)
1287
+ if (!existing || (platformSpecific && !existing.platformSpecific)) {
1288
+ filesByScale.set(candidate.scale, {
1289
+ file: join(assetDirectory, fileName),
1290
+ platformSpecific,
1291
+ })
1292
+ }
1293
+ }
1294
+
1295
+ const scales = [...filesByScale.keys()].sort((a, b) => a - b)
1296
+ const files = scales.map((scale) => filesByScale.get(scale)!.file)
1297
+ if (files.length === 0) {
1298
+ throw new Error(`[vxrn] native asset has no files for ${platform}: ${id}`)
1299
+ }
1300
+
1301
+ const hash = createHash('md5')
1302
+ for (const file of files) hash.update(readFileSync(file))
1303
+
1304
+ const assetData: NativeAssetData = {
1305
+ __packager_asset: true,
1306
+ name: requested.name,
1307
+ type: requested.type,
1308
+ scales,
1309
+ files,
1310
+ httpServerLocation: getNativeAssetUrlDirectory(root, assetDirectory),
1311
+ fileSystemLocation: assetDirectory,
1312
+ hash: hash.digest('hex'),
1313
+ }
1314
+
1315
+ if (NATIVE_IMAGE_EXTS.has(requested.type)) {
1316
+ try {
1317
+ const { imageSize } = await import('image-size')
1318
+ const dims = imageSize(files[0])
1319
+ assetData.width = dims.width === undefined ? undefined : dims.width / scales[0]
1320
+ assetData.height = dims.height === undefined ? undefined : dims.height / scales[0]
1321
+ } catch {}
1322
+ }
1323
+
1324
+ return assetData
1325
+ }
1326
+
1327
+ function getIOSAssetScales(scales: number[]): number[] {
1328
+ const supported = scales.filter((scale) => scale === 1 || scale === 2 || scale === 3)
1329
+ if (supported.length > 0 || scales.length === 0) return supported
1330
+ return [scales.find((scale) => scale > 3) ?? scales[scales.length - 1]]
1331
+ }
1332
+
1333
+ function getAndroidAssetDirectory(type: string, scale: number): string {
1334
+ if (
1335
+ !new Set(['gif', 'heic', 'heif', 'jpeg', 'jpg', 'ktx', 'png', 'webp', 'xml']).has(
1336
+ type
1337
+ )
1338
+ ) {
1339
+ return 'raw'
1340
+ }
1341
+
1342
+ const density = new Map([
1343
+ [0.75, 'ldpi'],
1344
+ [1, 'mdpi'],
1345
+ [1.5, 'hdpi'],
1346
+ [2, 'xhdpi'],
1347
+ [3, 'xxhdpi'],
1348
+ [4, 'xxxhdpi'],
1349
+ ]).get(scale)
1350
+ if (density) return `drawable-${density}`
1351
+ return `drawable-${Math.round(scale * 160)}dpi`
1352
+ }
1353
+
1354
+ function getAndroidAssetName(asset: NativeAssetData): string {
1355
+ return `${asset.httpServerLocation}/${asset.name}`
1356
+ .toLowerCase()
1357
+ .replace(/^\//, '')
1358
+ .replace(/\//g, '_')
1359
+ .replace(/([^a-z0-9_])/g, '')
1360
+ .replace(/^assets_/, '')
1361
+ }
1362
+
1363
+ function copyNativeAssetFiles(
1364
+ asset: NativeAssetData,
1365
+ assetsDest: string,
1366
+ platform: string
1367
+ ): void {
1368
+ const validIOSScales = new Set(getIOSAssetScales(asset.scales))
1369
+
1370
+ for (const [index, scale] of asset.scales.entries()) {
1371
+ if (platform === 'ios' && !validIOSScales.has(scale)) continue
1372
+
1373
+ const destination =
1374
+ platform === 'android'
1375
+ ? join(
1376
+ assetsDest,
1377
+ getAndroidAssetDirectory(asset.type, scale),
1378
+ `${getAndroidAssetName(asset)}.${asset.type}`
1379
+ )
1380
+ : join(
1381
+ assetsDest,
1382
+ asset.httpServerLocation.slice(1),
1383
+ `${asset.name}${scale === 1 ? '' : `@${scale}x`}.${asset.type}`
1384
+ )
1385
+ mkdirSync(dirname(destination), { recursive: true })
1386
+ copyFileSync(asset.files[index], destination)
1387
+ }
1388
+ }
1389
+
1128
1390
  /**
1129
1391
  * SWC transform for Hermes compatibility.
1130
1392
  * Transforms class properties and private fields that Hermes doesn't support.
1131
1393
  * Inspired by rollipop's swc-plugin.ts.
1132
1394
  */
1133
- export function hermesCompatSWCPlugin(dev: boolean): Plugin {
1395
+ export function hermesCompatSWCPlugin(dev: boolean, sourceMaps = false): Plugin {
1134
1396
  let swc: typeof import('@swc/core') | null = null
1135
1397
 
1136
1398
  return {
@@ -1140,43 +1402,43 @@ export function hermesCompatSWCPlugin(dev: boolean): Plugin {
1140
1402
  if (id.includes('\0') || id.includes('virtual:')) return
1141
1403
  // skip files that don't need transformation
1142
1404
  const hasClass = code.includes('class ') || code.includes('class{')
1143
- const hasAsync = !dev && code.includes('async ')
1144
- if (!hasClass && !hasAsync) return
1405
+ const hasAsync = code.includes('async ')
1406
+ const hasBlockScopedLoop = /\bfor\s*\(\s*(?:const|let)\b/.test(code)
1407
+ if (!hasClass && !hasAsync && !hasBlockScopedLoop) return
1145
1408
  // skip very large prebuilt files
1146
1409
  if (code.length > 500_000) return
1147
1410
 
1148
- try {
1149
- if (!swc) swc = await import('@swc/core')
1150
-
1151
- // app modules: the Hermes class set (unconditional), plus async-to-generator
1152
- // in prod only (see HERMES_CLASS_TRANSFORMS / HERMES_PROD_TRANSFORMS)
1153
- const envIncludes = getHermesSWCIncludes(dev)
1154
-
1155
- const result = await swc.transform(code, {
1156
- filename: id,
1157
- configFile: false,
1158
- swcrc: false,
1159
- sourceMaps: false,
1160
- inputSourceMap: false,
1161
- env: {
1162
- targets: { node: 9999 },
1163
- include: envIncludes,
1164
- },
1165
- jsc: {
1166
- parser: { syntax: 'typescript', tsx: true },
1167
- transform: { react: { runtime: 'preserve' } },
1168
- externalHelpers: false,
1169
- assumptions: {
1170
- setPublicClassFields: true,
1171
- privateFieldsAsProperties: true,
1172
- },
1411
+ if (!swc) swc = await import('@swc/core')
1412
+
1413
+ // app modules: the Hermes class and async sets in both modes.
1414
+ const envIncludes = getHermesSWCIncludes(dev)
1415
+
1416
+ const result = await swc.transform(code, {
1417
+ filename: id,
1418
+ configFile: false,
1419
+ swcrc: false,
1420
+ // Return the transform-to-input map and let Rolldown compose it with
1421
+ // earlier plugin maps. `inputSourceMap` stays false deliberately:
1422
+ // feeding the prior map to SWC as well would compose it twice.
1423
+ sourceMaps,
1424
+ sourceFileName: sourceMaps ? id : undefined,
1425
+ inputSourceMap: false,
1426
+ env: {
1427
+ targets: { node: 9999 },
1428
+ include: envIncludes,
1429
+ },
1430
+ jsc: {
1431
+ parser: { syntax: 'typescript', tsx: true },
1432
+ transform: { react: { runtime: 'preserve' } },
1433
+ externalHelpers: false,
1434
+ assumptions: {
1435
+ setPublicClassFields: true,
1436
+ privateFieldsAsProperties: true,
1173
1437
  },
1174
- isModule: !id.endsWith('.cjs'),
1175
- })
1176
- return { code: result.code }
1177
- } catch (err: any) {
1178
- // don't crash on SWC transform errors (eg static blocks not supported)
1179
- }
1438
+ },
1439
+ isModule: !id.endsWith('.cjs'),
1440
+ })
1441
+ return { code: result.code, map: sourceMaps ? result.map : undefined }
1180
1442
  },
1181
1443
  }
1182
1444
  }
@@ -1201,6 +1463,7 @@ class ReactNativeDevRuntime extends BaseDevRuntime {
1201
1463
  var runtime = this;
1202
1464
  var ctx = {
1203
1465
  acceptCallbacks: [],
1466
+ reactRefreshAcceptCallback: null,
1204
1467
  accept: function(deps, callback) {
1205
1468
  if (typeof deps === 'function' || !deps) {
1206
1469
  ctx.acceptCallbacks.push({
@@ -1216,6 +1479,9 @@ class ReactNativeDevRuntime extends BaseDevRuntime {
1216
1479
  ctx.acceptCallbacks.push({ deps: deps, fn: callback || function() {} });
1217
1480
  }
1218
1481
  },
1482
+ acceptReactRefresh: function(callback) {
1483
+ ctx.reactRefreshAcceptCallback = callback;
1484
+ },
1219
1485
  invalidate: function() { runtime.requestReload('module invalidated: ' + moduleId); },
1220
1486
  on: function() {},
1221
1487
  off: function() {},
@@ -1249,9 +1515,18 @@ class ReactNativeDevRuntime extends BaseDevRuntime {
1249
1515
 
1250
1516
  isSelfAccepted(moduleId) {
1251
1517
  var ctx = this.moduleHotContexts[moduleId];
1252
- return !!(ctx && ctx.acceptCallbacks.some(function(callback) {
1518
+ var explicitlyAccepted = !!(ctx && ctx.acceptCallbacks.some(function(callback) {
1253
1519
  return callback.deps.indexOf(moduleId) !== -1;
1254
1520
  }));
1521
+ if (explicitlyAccepted) return { accepted: true, reactRefresh: false };
1522
+ if (!ctx || !ctx.reactRefreshAcceptCallback || !globalThis.__ReactRefresh) {
1523
+ return { accepted: false, reactRefresh: false };
1524
+ }
1525
+ var exports = this.loadExports(moduleId);
1526
+ return {
1527
+ accepted: ctx.refreshUtils.isReactRefreshBoundary(exports),
1528
+ reactRefresh: true
1529
+ };
1255
1530
  }
1256
1531
 
1257
1532
  acceptsDependency(parentId, moduleId) {
@@ -1266,8 +1541,13 @@ class ReactNativeDevRuntime extends BaseDevRuntime {
1266
1541
  traversed.add(moduleId);
1267
1542
  updateSet.add(moduleId);
1268
1543
 
1269
- if (this.isSelfAccepted(moduleId)) {
1270
- boundaries.push({ boundary: moduleId, acceptedVia: moduleId });
1544
+ var selfAcceptance = this.isSelfAccepted(moduleId);
1545
+ if (selfAcceptance.accepted) {
1546
+ boundaries.push({
1547
+ boundary: moduleId,
1548
+ acceptedVia: moduleId,
1549
+ reactRefresh: selfAcceptance.reactRefresh
1550
+ });
1271
1551
  return true;
1272
1552
  }
1273
1553
 
@@ -1303,12 +1583,19 @@ class ReactNativeDevRuntime extends BaseDevRuntime {
1303
1583
 
1304
1584
  var callbacks = boundaries.map(function(item) {
1305
1585
  var ctx = this.moduleHotContexts[item.boundary];
1586
+ var selectedCallbacks = ctx ? ctx.acceptCallbacks.filter(function(callback) {
1587
+ return callback.deps.indexOf(item.acceptedVia) !== -1;
1588
+ }) : [];
1589
+ if (item.reactRefresh && ctx && ctx.reactRefreshAcceptCallback) {
1590
+ selectedCallbacks.push({
1591
+ deps: [item.acceptedVia],
1592
+ fn: function() { ctx.reactRefreshAcceptCallback(); }
1593
+ });
1594
+ }
1306
1595
  return {
1307
1596
  boundary: item.boundary,
1308
1597
  acceptedVia: item.acceptedVia,
1309
- callbacks: ctx ? ctx.acceptCallbacks.filter(function(callback) {
1310
- return callback.deps.indexOf(item.acceptedVia) !== -1;
1311
- }) : []
1598
+ callbacks: selectedCallbacks
1312
1599
  };
1313
1600
  }, this);
1314
1601