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
@@ -14,9 +14,11 @@ import {
14
14
  mkdirSync,
15
15
  realpathSync,
16
16
  readdirSync,
17
+ statSync,
17
18
  readFileSync,
18
19
  } from 'node:fs'
19
20
  import { pathToFileURL } from 'node:url'
21
+ import micromatch from 'micromatch'
20
22
  import type { InputOptions, OutputOptions, Plugin, RolldownOutput } from 'rolldown'
21
23
  import type { DevEngine } from 'rolldown/experimental'
22
24
  import { loadEnv as loadViteEnv, normalizePath } from 'vite'
@@ -52,6 +54,12 @@ export function getHermesSWCIncludes(dev: boolean): string[] {
52
54
  return [...HERMES_CLASS_TRANSFORMS, ...HERMES_ASYNC_TRANSFORMS]
53
55
  }
54
56
 
57
+ export interface NativePluginContext {
58
+ root: string
59
+ platform: 'ios' | 'android'
60
+ dev: boolean
61
+ }
62
+
55
63
  interface NativeDevEngineOptions {
56
64
  root: string
57
65
  port: number
@@ -70,14 +78,27 @@ export type NativeHmrUpdate =
70
78
  changedIds: string[]
71
79
  seq: number
72
80
  }
73
- | { type: 'hmr:reload'; clientId: string }
81
+ // a reload with no clientId goes to every client on the platform: a full
82
+ // build re-expands the route globs for all of them at once
83
+ | { type: 'hmr:reload'; clientId?: string }
74
84
  | { type: 'hmr:error' }
75
85
 
86
+ /**
87
+ * The served dev bundle and the map that resolves a frame in it back to the
88
+ * authored file. `map` is the serialized JSON, parsed only when a symbolicate
89
+ * request arrives.
90
+ */
91
+ export interface NativeDevBundle {
92
+ code: string
93
+ map: string
94
+ }
95
+
76
96
  interface NativeDevEngineResult {
77
97
  engine: DevEngine
78
- getBundle: () => Promise<{ code: string }>
98
+ getBundle: () => Promise<NativeDevBundle>
79
99
  getAsset: (pathname: string, hash?: string) => NativeDevAsset | undefined
80
100
  close: () => Promise<void>
101
+ handleAddedFile: (file: string) => Promise<void>
81
102
  }
82
103
 
83
104
  // shared resolve extensions for native builds
@@ -221,13 +242,15 @@ export function getNativeTransformConfig(
221
242
  // shared plugins used by both dev and prod native builds
222
243
  function getNativePlugins(
223
244
  root: string,
224
- platform: string,
245
+ platform: NativePluginContext['platform'],
225
246
  viteImportGlobPlugin: any,
226
247
  dev: boolean,
227
248
  assetsDest?: string,
228
249
  onAsset?: (asset: NativeAssetData) => void,
229
- sourceMaps = false
250
+ sourceMaps = false,
251
+ userPlugins: Plugin[] = []
230
252
  ): Plugin[] {
253
+ const context: NativePluginContext = { root, platform, dev }
231
254
  return [
232
255
  // plugins provided by One (clientTreeShakePlugin for loader removal, etc.)
233
256
  ...(globalThis.__vxrnAddNativePlugins || []),
@@ -264,17 +287,31 @@ function getNativePlugins(
264
287
  // hermes compat: per-iteration loop bindings. runs last so it also covers
265
288
  // loops the earlier lowering steps emit.
266
289
  hermesLoopsPlugin(sourceMaps),
267
- ]
290
+ ...userPlugins,
291
+ ].map((plugin: Plugin) =>
292
+ plugin.api?.vxrnNative ? plugin.api.vxrnNative(context) : plugin
293
+ )
268
294
  }
269
295
 
270
- // shared output options for native builds
271
- function getNativeOutputOptions(prelude: string, sourcemap: boolean): OutputOptions {
296
+ // shared output options for native builds. `minify: false` keeps rolldown's
297
+ // default `'dce-only'`, which is what native builds have always run with.
298
+ function getNativeOutputOptions(
299
+ prelude: string,
300
+ sourcemap: boolean,
301
+ minify: boolean
302
+ ): OutputOptions {
272
303
  return {
273
304
  format: 'esm',
274
305
  sourcemap,
306
+ // emit absolute source paths, the way Metro does. a relative path is only
307
+ // meaningful next to the map file, and neither /symbolicate nor a crash
308
+ // reporter reading the shipped .map has that directory.
309
+ sourcemapPathTransform: (relativeSourcePath, sourcemapPath) =>
310
+ normalizePath(resolve(dirname(sourcemapPath), relativeSourcePath)),
275
311
  intro: prelude,
276
312
  codeSplitting: false,
277
313
  strictExecutionOrder: true,
314
+ minify: minify || 'dce-only',
278
315
  }
279
316
  }
280
317
 
@@ -293,7 +330,40 @@ export function normalizeNativeCommonJSInterop(code: string): string {
293
330
  )
294
331
  }
295
332
 
296
- function postProcessNativeBundle(code: string): string {
333
+ /**
334
+ * Every post-processing pass below rewrites the bundle after Rolldown has
335
+ * already emitted its source map, so a pass that changes the number of lines
336
+ * shifts every later frame away from the source it maps to. Replacing a removed
337
+ * span with its own newlines keeps each surviving statement on the line the map
338
+ * recorded for it.
339
+ */
340
+ function blankPreservingLines(match: string): string {
341
+ return '\n'.repeat(match.match(/\n/g)?.length ?? 0)
342
+ }
343
+
344
+ function countLines(code: string): number {
345
+ return (code.match(/\n/g)?.length ?? 0) + 1
346
+ }
347
+
348
+ /**
349
+ * Guard the source map's only invariant: application code has to stay on the
350
+ * line Rolldown mapped it to. A pass that gains or loses a line is a bug in that
351
+ * pass, and a symbolicated frame that silently points a few lines off is worse
352
+ * than a build that stops and says so.
353
+ */
354
+ function assertBundleLinesPreserved(before: string, after: string, stage: string): void {
355
+ const beforeLines = countLines(before)
356
+ const afterLines = countLines(after)
357
+ if (beforeLines !== afterLines) {
358
+ throw new Error(
359
+ `[vxrn] native bundle post-processing (${stage}) changed the line count ` +
360
+ `(${beforeLines} -> ${afterLines}), which invalidates the source map. ` +
361
+ `Every pass after Rolldown emits the map must preserve lines.`
362
+ )
363
+ }
364
+ }
365
+
366
+ export function postProcessNativeBundle(code: string): string {
297
367
  code = normalizeNativeCommonJSInterop(code)
298
368
 
299
369
  // Rolldown replaces import.meta.env reads but can leave a guarding
@@ -302,9 +372,17 @@ function postProcessNativeBundle(code: string): string {
302
372
  code = code.replace(/\btypeof\s+import\.meta\b/g, '"object"')
303
373
 
304
374
  // rolldown devMode still emits ESM export statements that hermes can't parse.
305
- // this is a rolldown behavior we can't configure away yet.
306
- code = code.replace(/^\s*export\s*\{[^}]*\}\s*;?\s*$/gm, '')
307
- code = code.replace(/^\s*export\s+default\s+([^;\n]+);?\s*$/gm, '$1;')
375
+ // this is a rolldown behavior we can't configure away yet. both patterns match
376
+ // horizontal whitespace only: a plain `\s` swallows the blank line above the
377
+ // statement, and a swallowed line moves every frame below it.
378
+ code = code.replace(
379
+ /^[^\S\n]*export[^\S\n]*\{[^}]*\}[^\S\n]*;?[^\S\n]*$/gm,
380
+ blankPreservingLines
381
+ )
382
+ code = code.replace(
383
+ /^([^\S\n]*)export[^\S\n]+default[^\S\n]+([^;\n]+);?[^\S\n]*$/gm,
384
+ '$1$2;'
385
+ )
308
386
  // rolldown devMode runtime leaves some raw import.meta.hot references
309
387
  // that aren't compiled through the normal plugin pipeline.
310
388
  code = code.replace(/^if \(import\.meta\.hot\).*$/gm, '')
@@ -322,9 +400,11 @@ function postProcessNativeBundle(code: string): string {
322
400
  const afterMarker = code.indexOf('})();', idx)
323
401
  if (afterMarker !== -1) {
324
402
  const end = afterMarker + '})();'.length
403
+ const removed = code.slice(beforeMarker, end)
325
404
  code =
326
405
  code.slice(0, beforeMarker) +
327
406
  'NativeAnimatedModule = NativeAnimatedModule_default ?? NativeAnimatedTurboModule_default;' +
407
+ blankPreservingLines(removed) +
328
408
  code.slice(end)
329
409
  }
330
410
  }
@@ -365,7 +445,10 @@ export function wrapNativeBundleModuleScope(code: string): string {
365
445
  const idx = code.indexOf(marker)
366
446
  if (idx === -1) return code
367
447
 
368
- return code.slice(0, idx) + ';(function() {\n' + code.slice(idx) + '\n})();\n'
448
+ // the opener shares the marker's line (the rest of that line is a comment) so
449
+ // the wrap costs no lines, and the closer only appends past the last mapped
450
+ // line. the served bundle therefore stays aligned with Rolldown's source map.
451
+ return code.slice(0, idx) + ';(function() {' + code.slice(idx) + '\n})();\n'
369
452
  }
370
453
 
371
454
  /**
@@ -415,6 +498,33 @@ async function downlevelClassFieldsInBundle(code: string): Promise<string> {
415
498
  return code.slice(0, startIdx) + linePreservingRuntime + code.slice(runtimeEnd)
416
499
  }
417
500
 
501
+ /**
502
+ * Run the native post-processing passes inside the bundle, where rolldown still
503
+ * hands them unminified output.
504
+ *
505
+ * Rolldown minifies a chunk *after* plugin `renderChunk` hooks, and every pass
506
+ * below reads the shape rolldown generates: `normalizeNativeCommonJSInterop`
507
+ * matches `__toESM(require_x(), 1)` by name, and `downlevelClassFieldsInBundle`
508
+ * locates the runtime by its `//#region` comment. Minification mangles the
509
+ * first and strips the second, so running these on the finished chunk would
510
+ * silently stop applying them as soon as `--minify` is on — a production bundle
511
+ * whose CommonJS interop differs from the dev one. This hook is what keeps a
512
+ * minified and an unminified bundle the same bundle.
513
+ *
514
+ * `map: null` records what the passes guarantee: they replace syntax in place
515
+ * without moving a line, so rolldown's own map still describes the result.
516
+ */
517
+ function nativeBundlePostProcessPlugin(): Plugin {
518
+ return {
519
+ name: 'vxrn:native-post-process',
520
+ async renderChunk(code) {
521
+ const processed = await downlevelClassFieldsInBundle(postProcessNativeBundle(code))
522
+ assertBundleLinesPreserved(code, processed, 'production')
523
+ return { code: processed, map: null }
524
+ },
525
+ }
526
+ }
527
+
418
528
  export async function createNativeDevEngine(
419
529
  options: NativeDevEngineOptions
420
530
  ): Promise<NativeDevEngineResult> {
@@ -439,13 +549,25 @@ export async function createNativeDevEngine(
439
549
  })
440
550
  const assetRegistry = createNativeDevAssetRegistry()
441
551
 
442
- let currentBundle: { code: string } | null = null
552
+ let currentBundle: NativeDevBundle | null = null
443
553
  let firstBuildError: Error | null = null
444
- let bundleResolve: ((value: { code: string }) => void) | null = null
554
+ let bundleResolve: ((value: NativeDevBundle) => void) | null = null
445
555
  let bundleReject: ((error: Error) => void) | null = null
446
- let bundlePromise: Promise<{ code: string }> | null = null
556
+ let bundlePromise: Promise<NativeDevBundle> | null = null
447
557
 
448
558
  const resolvedHost = host === '0.0.0.0' ? 'localhost' : host
559
+ const virtualEntry = nativeVirtualEntryPlugin(root, { dev: true })
560
+
561
+ // one build at a time: a bundle request and a full rebuild can arrive
562
+ // together (a reload lands on the bundle route while a new route file is
563
+ // being picked up), and the engine holds a single output the second would
564
+ // race the first for.
565
+ let engineWork: Promise<unknown> = Promise.resolve()
566
+ const queueEngineWork = <T>(work: () => Promise<T>): Promise<T> => {
567
+ const result = engineWork.then(work, work)
568
+ engineWork = result.catch(() => {})
569
+ return result
570
+ }
449
571
 
450
572
  const inputOptions: InputOptions = {
451
573
  input: VIRTUAL_NATIVE_ENTRY,
@@ -475,7 +597,7 @@ export async function createNativeDevEngine(
475
597
  },
476
598
 
477
599
  plugins: [
478
- nativeVirtualEntryPlugin(root, { dev: true }),
600
+ virtualEntry.plugin,
479
601
  ...getNativePlugins(
480
602
  root,
481
603
  platform,
@@ -483,91 +605,78 @@ export async function createNativeDevEngine(
483
605
  true,
484
606
  undefined,
485
607
  assetRegistry.register,
486
- false
608
+ // per-module maps: without them a module's transform output has no path
609
+ // back to its authored file, and /symbolicate can only report the
610
+ // bundle offset it was handed.
611
+ true,
612
+ userPlugins
487
613
  ),
488
- ...userPlugins,
489
614
  ],
490
615
  }
491
616
 
492
617
  const outputOptions: OutputOptions = {
493
- // no dev sourcemap: nothing consumes one. the bundle handler serves .code and
494
- // /symbolicate isn't implemented, and the map wouldn't line up anyway — the
495
- // served code is post-processed (runtime downleveling, IIFE wrap) after the
496
- // map is generated. generating it cost ~90ms and ~250MB RSS per rebuild on a
497
- // 6MB bundle, held for the life of the dev server, per platform.
498
- ...getNativeOutputOptions(prelude, false),
499
- // connect HMR WebSocket using RN's WebSocket module (not the global)
618
+ // the dev map is what /symbolicate resolves a device frame through. it is
619
+ // generated once per full bundle build, not per edit: rebuildStrategy is
620
+ // 'never', so Fast Refresh patches never re-enter this path. a dev bundle
621
+ // is never minified.
622
+ ...getNativeOutputOptions(prelude, true, false),
623
+ // open the HMR socket with RN's WebSocket module (not the global, which is
624
+ // only polyfilled once InitializeCore has run) and hand it to the runtime,
625
+ // which owns every message it carries. handed over immediately rather than
626
+ // from an open handler: a host that does not honor `socket.onopen = fn`
627
+ // would otherwise silently never get Fast Refresh.
500
628
  outro: `
501
629
  try {
502
630
  var __WS = (init_WebSocket(), __toCommonJS(WebSocket_exports)).default;
503
631
  var __hmrUrl = 'ws://${resolvedHost}:${port}/hot?platform=${platform}&clientId=' + encodeURIComponent(__rolldown_runtime__.clientId);
504
- var __hmrWS = new __WS(__hmrUrl);
505
- __hmrWS.onmessage = function(event) {
506
- try {
507
- var msg = JSON.parse(event.data);
508
- var g = typeof global !== 'undefined' ? global : globalThis;
509
- if (msg.type === 'hmr:update' && msg.code) {
510
- var applied = __rolldown_runtime__.applyHmrUpdate(msg.code, msg.changedIds, msg.seq);
511
- if (!applied) {
512
- var updateSettings = g.__turboModuleProxy ? g.__turboModuleProxy('DevSettings') : null;
513
- if (updateSettings && updateSettings.reload) updateSettings.reload();
514
- }
515
- } else if (msg.type === 'hmr:reload') {
516
- var ds = g.__turboModuleProxy ? g.__turboModuleProxy('DevSettings') : null;
517
- if (ds && ds.reload) ds.reload();
518
- }
519
- } catch(e) { console.error('[vxrn] HMR eval error:', e); }
520
- };
521
- __hmrWS.onopen = function() {
522
- if (typeof __rolldown_runtime__ !== 'undefined' && __rolldown_runtime__.setup) {
523
- __rolldown_runtime__.setup(__hmrWS);
524
- }
525
- };
526
- __hmrWS.onerror = function(e) { console.warn('[vxrn] HMR connection error:', e.message || e); };
527
- } catch(e) {}
632
+ __rolldown_runtime__.setup(new __WS(__hmrUrl));
633
+ } catch(e) {
634
+ // a swallowed failure here leaves the app permanently without Fast Refresh and
635
+ // nothing on screen or in the terminal says so.
636
+ console.error('[vxrn] HMR client failed to start:', (e && e.message) || e);
637
+ }
528
638
  `,
529
639
  }
530
640
 
531
- const engine = await dev(inputOptions, outputOptions, {
532
- onOutput: async (result) => {
533
- if (result instanceof Error) {
534
- console.error('[vxrn] native bundle error:', result.message)
535
- if (!currentBundle) {
536
- firstBuildError ||= result
537
- if (bundleReject) {
538
- const reject = bundleReject
539
- bundleResolve = null
540
- bundleReject = null
541
- bundlePromise = null
542
- reject(firstBuildError)
543
- }
544
- }
545
- return
546
- }
641
+ // rolldown does not await this callback, so `ensureLatestBuildOutput()`
642
+ // resolves while the emitted chunk is still being post-processed and
643
+ // `currentBundle` still holds the previous build. anything that has to see
644
+ // the finished bundle waits on this too.
645
+ let outputProcessed: Promise<void> = Promise.resolve()
646
+
647
+ let engine: Awaited<ReturnType<typeof dev>>
648
+
649
+ const rebuildIfNewRoute = async (files: string[]): Promise<boolean> => {
650
+ const { routeRoot, files: knownRoutes, isRouteFile } = virtualEntry.routes
651
+ const routeRootPrefix = `${normalizePath(routeRoot)}/`
652
+ const routeAdded = files.some((file) => {
653
+ const normalized = normalizePath(file)
654
+ if (isRouteFile(normalized)) return !knownRoutes.has(normalized)
655
+ return (
656
+ normalized.startsWith(routeRootPrefix) &&
657
+ statSync(file, { throwIfNoEntry: false })?.isDirectory() === true
658
+ )
659
+ })
660
+ if (!routeAdded) return false
661
+ await queueEngineWork(async () => {
662
+ engine.triggerFullBuild()
663
+ await engine.ensureLatestBuildOutput()
664
+ await outputProcessed
665
+ })
666
+ onHmrUpdate?.({ type: 'hmr:reload' })
667
+ return true
668
+ }
547
669
 
548
- const output = result as RolldownOutput
549
- const chunk = output.output.find((o) => o.type === 'chunk' && o.isEntry)
550
- if (chunk && 'code' in chunk) {
551
- firstBuildError = null
552
- let code = postProcessNativeBundle(chunk.code)
553
-
554
- // downlevel class fields from the rolldown runtime (virtual module
555
- // skipped by the per-file SWC plugin) so old Hermes can parse them
556
- code = await downlevelClassFieldsInBundle(code)
557
-
558
- // wrap module code in a function scope so top-level `var`s (e.g. RN
559
- // fetch.js's `Headers`/`Request`) don't leak as non-configurable
560
- // globals and break RN's polyfillGlobal (dev-only redbox). see fn doc.
561
- code = wrapNativeBundleModuleScope(code)
562
-
563
- currentBundle = { code }
564
- console.info(`[vxrn] native bundle ready (${Math.round(code.length / 1024)}KB)`)
565
- if (bundleResolve) {
566
- bundleResolve(currentBundle)
567
- bundleResolve = null
568
- bundleReject = null
569
- bundlePromise = null
570
- }
670
+ engine = await dev(inputOptions, outputOptions, {
671
+ onOutput: async (result) => {
672
+ let finishOutput = () => {}
673
+ outputProcessed = new Promise<void>((resolve) => {
674
+ finishOutput = resolve
675
+ })
676
+ try {
677
+ await handleOutput(result)
678
+ } finally {
679
+ finishOutput()
571
680
  }
572
681
  },
573
682
 
@@ -577,6 +686,14 @@ try {
577
686
  onHmrUpdate?.({ type: 'hmr:error' })
578
687
  return
579
688
  }
689
+
690
+ // a route file that was just created has no module, so rolldown reports
691
+ // the change and stops: in devMode it never re-scans, and the entry's
692
+ // `import.meta.glob` was expanded back when it transformed the entry.
693
+ // only a full build re-expands it, and rolldown tells no client that
694
+ // happened, so the reload is sent from here.
695
+ if (await rebuildIfNewRoute(result.changedFiles)) return
696
+
580
697
  for (const { clientId, update } of result.updates) {
581
698
  if (update.type === 'Patch' && update.code) {
582
699
  onHmrUpdate?.({
@@ -598,6 +715,67 @@ try {
598
715
  watch: {},
599
716
  })
600
717
 
718
+ async function handleOutput(result: RolldownOutput | Error) {
719
+ if (result instanceof Error) {
720
+ console.error('[vxrn] native bundle error:', result.message)
721
+ if (/panic/i.test(result.message)) {
722
+ // a panicked rolldown worker stops producing HMR patches for the rest
723
+ // of the process, so every later edit reloads the whole app. nothing
724
+ // else says so, and the app looks fine.
725
+ console.error(
726
+ '[vxrn] rolldown itself panicked. Fast Refresh will full-reload until you restart the dev server.'
727
+ )
728
+ }
729
+ if (!currentBundle) {
730
+ firstBuildError ||= result
731
+ if (bundleReject) {
732
+ const reject = bundleReject
733
+ bundleResolve = null
734
+ bundleReject = null
735
+ bundlePromise = null
736
+ reject(firstBuildError)
737
+ }
738
+ }
739
+ return
740
+ }
741
+
742
+ const output = result as RolldownOutput
743
+ const chunk = output.output.find((o) => o.type === 'chunk' && o.isEntry)
744
+ if (chunk && 'code' in chunk) {
745
+ firstBuildError = null
746
+ let code = postProcessNativeBundle(chunk.code)
747
+
748
+ // downlevel class fields from the rolldown runtime (virtual module
749
+ // skipped by the per-file SWC plugin) so old Hermes can parse them
750
+ code = await downlevelClassFieldsInBundle(code)
751
+ assertBundleLinesPreserved(chunk.code, code, 'dev')
752
+
753
+ // wrap module code in a function scope so top-level `var`s (e.g. RN
754
+ // fetch.js's `Headers`/`Request`) don't leak as non-configurable
755
+ // globals and break RN's polyfillGlobal (dev-only redbox). see fn doc.
756
+ // it only appends past the last mapped line, so it needs no assertion.
757
+ code = wrapNativeBundleModuleScope(code)
758
+
759
+ if (!chunk.map) {
760
+ throw new Error(
761
+ '[vxrn] rolldown produced no source map for the dev bundle, so /symbolicate cannot resolve a frame'
762
+ )
763
+ }
764
+ // hold the serialized map, not a parsed one: parsing a map for a bundle
765
+ // this size costs hundreds of megabytes that would stay resident for the
766
+ // life of the dev server, and /symbolicate runs only when a frame needs
767
+ // resolving.
768
+ currentBundle = { code, map: chunk.map.toString() }
769
+ console.info(`[vxrn] native bundle ready (${Math.round(code.length / 1024)}KB)`)
770
+ if (bundleResolve) {
771
+ bundleResolve(currentBundle)
772
+ bundleResolve = null
773
+ bundleReject = null
774
+ bundlePromise = null
775
+ }
776
+ }
777
+ }
778
+
601
779
  await engine.run()
602
780
 
603
781
  return {
@@ -606,7 +784,10 @@ try {
606
784
  async getBundle() {
607
785
  // a runtime invalidation marks the full output stale before reloading. pull
608
786
  // that output before serving the cached bundle to the restarted app.
609
- await engine.ensureLatestBuildOutput()
787
+ await queueEngineWork(async () => {
788
+ await engine.ensureLatestBuildOutput()
789
+ await outputProcessed
790
+ })
610
791
  if (currentBundle) return currentBundle
611
792
  if (firstBuildError) throw firstBuildError
612
793
  if (!bundlePromise) {
@@ -638,6 +819,14 @@ try {
638
819
  async close() {
639
820
  await engine.close()
640
821
  },
822
+
823
+ // vite's watcher sees a created file even when rolldown's addWatchFile
824
+ // directory watches do not, which is what happens on github's macos
825
+ // runners. scoped to route files so a random write in the project is not
826
+ // a full rebuild.
827
+ async handleAddedFile(file: string) {
828
+ await rebuildIfNewRoute([file])
829
+ },
641
830
  }
642
831
  }
643
832
 
@@ -653,6 +842,11 @@ interface NativeBuildOptions {
653
842
  plugins?: Plugin[]
654
843
  /** only pass when the map is written somewhere — it costs a second copy of the bundle */
655
844
  sourcemap?: boolean
845
+ /**
846
+ * Compress and mangle the output. Defaults to React Native's own rule for a
847
+ * bundle: on unless the build is a dev build.
848
+ */
849
+ minify?: boolean
656
850
  }
657
851
 
658
852
  export async function buildNativeBundle(
@@ -667,6 +861,7 @@ export async function buildNativeBundle(
667
861
  assetsDest,
668
862
  plugins: userPlugins = [],
669
863
  sourcemap = false,
864
+ minify = !dev,
670
865
  } = options
671
866
 
672
867
  const { build } = await import('rolldown')
@@ -699,7 +894,7 @@ export async function buildNativeBundle(
699
894
  shimMissingExports: true,
700
895
  moduleTypes: { '.js': 'jsx' },
701
896
  plugins: [
702
- ...(entryFile ? [] : [nativeVirtualEntryPlugin(root, { dev })]),
897
+ ...(entryFile ? [] : [nativeVirtualEntryPlugin(root, { dev }).plugin]),
703
898
  ...getNativePlugins(
704
899
  root,
705
900
  platform,
@@ -707,11 +902,13 @@ export async function buildNativeBundle(
707
902
  dev,
708
903
  assetsDest,
709
904
  undefined,
710
- sourcemap
905
+ sourcemap,
906
+ userPlugins
711
907
  ),
712
- ...userPlugins,
908
+ // last, so it sees what every other plugin produced
909
+ nativeBundlePostProcessPlugin(),
713
910
  ],
714
- output: getNativeOutputOptions(prelude, sourcemap),
911
+ output: getNativeOutputOptions(prelude, sourcemap, minify),
715
912
  })
716
913
  const chunk = result.output.find((o) => o.type === 'chunk' && o.isEntry)
717
914
 
@@ -719,18 +916,38 @@ export async function buildNativeBundle(
719
916
  throw new Error('[vxrn] production build produced no output')
720
917
  }
721
918
 
722
- let code = postProcessNativeBundle(chunk.code)
723
- code = await downlevelClassFieldsInBundle(code)
724
- // Per-module Babel/SWC transforms return maps when requested, and the
725
- // generated-runtime downlevel pass preserves its input line count. The
726
- // remaining post-processing replaces syntax in place, so application frames
727
- // retain Rolldown's generated line and compose back to original source.
728
- return { code, map: sourcemap ? chunk.map?.toString() : undefined }
919
+ if (sourcemap && !chunk.map) {
920
+ throw new Error(
921
+ '[vxrn] a source map was requested but rolldown produced none for the production bundle'
922
+ )
923
+ }
924
+ // Per-module Babel/SWC transforms return maps when requested, and
925
+ // nativeBundlePostProcessPlugin runs inside the bundle, so Rolldown's map
926
+ // already describes the emitted chunk and composes back to original source.
927
+ return {
928
+ code: chunk.code,
929
+ map: sourcemap ? chunk.map?.toString() : undefined,
930
+ }
729
931
  }
730
932
 
731
933
  const VIRTUAL_NATIVE_ENTRY = 'virtual:native-entry'
732
934
 
733
- function nativeVirtualEntryPlugin(root: string, opts?: { dev?: boolean }): Plugin {
935
+ /**
936
+ * The route files the last build expanded `import.meta.glob` over. rolldown's
937
+ * dev engine reports a change to a watched file it holds no module for, but in
938
+ * devMode it never re-scans, so this is what tells a created route apart from
939
+ * an edit to one that already exists.
940
+ */
941
+ interface NativeRouteRegistry {
942
+ routeRoot: string
943
+ files: Set<string>
944
+ isRouteFile: (file: string) => boolean
945
+ }
946
+
947
+ function nativeVirtualEntryPlugin(
948
+ root: string,
949
+ opts?: { dev?: boolean }
950
+ ): { plugin: Plugin; routes: NativeRouteRegistry } {
734
951
  const isDev = opts?.dev !== false
735
952
  // absolute for import.meta.glob resolution; forward-slash for module-graph convention
736
953
  const resolvedId = normalizePath(resolve(root, '__virtual-native-entry.tsx'))
@@ -810,17 +1027,71 @@ createApp({
810
1027
  });
811
1028
  `
812
1029
 
1030
+ const routeRoot = resolve(root, routerRoot)
1031
+ // the entry's globs are written for `import.meta.glob`, which reads a leading
1032
+ // `!` as an exclusion and a leading `./` as the project root. micromatch
1033
+ // reads neither: `!p` matches everything except p, so a negation would match
1034
+ // every ordinary file, and `./app/x` never matches `./app/**`. split the
1035
+ // exclusions out and drop the `./` from both sides to keep the same meaning.
1036
+ const stripDot = (pattern: string) => pattern.replace(/^\.\//, '')
1037
+ const includeGlobs = routeGlobs.filter((p) => !p.startsWith('!')).map(stripDot)
1038
+ const excludeGlobs = routeGlobs
1039
+ .filter((p) => p.startsWith('!'))
1040
+ .map((p) => stripDot(p.slice(1)))
1041
+ const isRouteFile = (file: string) => {
1042
+ const relativeToRoot = normalizePath(relative(root, file))
1043
+ return (
1044
+ micromatch.isMatch(relativeToRoot, includeGlobs) &&
1045
+ !micromatch.isMatch(relativeToRoot, excludeGlobs)
1046
+ )
1047
+ }
1048
+ const routes: NativeRouteRegistry = {
1049
+ routeRoot,
1050
+ files: new Set(),
1051
+ isRouteFile,
1052
+ }
1053
+
813
1054
  return {
814
- name: 'vxrn:native-virtual-entry',
815
- resolveId(id) {
816
- if (id === VIRTUAL_NATIVE_ENTRY) {
817
- return resolvedId
818
- }
819
- },
820
- load(id) {
821
- if (id === resolvedId) {
1055
+ routes,
1056
+ plugin: {
1057
+ name: 'vxrn:native-virtual-entry',
1058
+ resolveId(id) {
1059
+ if (id === VIRTUAL_NATIVE_ENTRY) {
1060
+ return resolvedId
1061
+ }
1062
+ },
1063
+ load(id) {
1064
+ if (id !== resolvedId) return
1065
+ // rolldown expands `import.meta.glob` during transform and records a
1066
+ // dependency on the files it matched, never on the directories it
1067
+ // searched. registering those directories is what makes its own watcher
1068
+ // report a route file appearing, and walking them is what lets the
1069
+ // engine tell that report apart from an edit.
1070
+ //
1071
+ // watching a directory is not recursive, so every directory under the
1072
+ // route root is registered. a new subdirectory is itself a change to
1073
+ // its watched parent, and the full build that follows re-runs this
1074
+ // hook, so the build that picks up `app/foo/index.tsx` is also what
1075
+ // starts watching `app/foo`.
1076
+ routes.files.clear()
1077
+ const walkRouteRoot = (dir: string) => {
1078
+ this.addWatchFile(dir)
1079
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1080
+ const child = resolve(dir, entry.name)
1081
+ if (entry.isDirectory()) walkRouteRoot(child)
1082
+ else if (isRouteFile(child)) routes.files.add(normalizePath(child))
1083
+ }
1084
+ }
1085
+ // a project can have no route root at all: `import.meta.glob` then
1086
+ // expands to nothing and the app builds fine. watch the directory that
1087
+ // would hold it, so creating it is the change that starts the walk.
1088
+ if (statSync(routeRoot, { throwIfNoEntry: false })?.isDirectory()) {
1089
+ walkRouteRoot(routeRoot)
1090
+ } else {
1091
+ this.addWatchFile(dirname(routeRoot))
1092
+ }
822
1093
  return entryCode
823
- }
1094
+ },
824
1095
  },
825
1096
  }
826
1097
  }
@@ -1029,7 +1300,9 @@ export function vxrnCompilerPlugin(
1029
1300
  )
1030
1301
  }
1031
1302
  const workletEntry = babelOptions?.plugins?.find(isWorkletPlugin)
1032
- const workletPluginOptions = Array.isArray(workletEntry) ? workletEntry[1] : undefined
1303
+ const workletPluginOptions = Array.isArray(workletEntry)
1304
+ ? workletEntry[1]
1305
+ : undefined
1033
1306
  const useWorklets =
1034
1307
  compiler.isNativeWorkletsEnabled() &&
1035
1308
  (Boolean(workletEntry) || compiler.shouldTransformWorklets({ id, code }))
@@ -1041,7 +1314,11 @@ export function vxrnCompilerPlugin(
1041
1314
  if (compilerPluginIndex !== -1) {
1042
1315
  // preserve automatic worklet candidates before react compiler hoists them.
1043
1316
  if (useWorklets) {
1044
- const prepared = compiler.prepareWorkletsForReactCompiler(id, curCode, sourceMaps)
1317
+ const prepared = compiler.prepareWorkletsForReactCompiler(
1318
+ id,
1319
+ curCode,
1320
+ sourceMaps
1321
+ )
1045
1322
  if (prepared) {
1046
1323
  curCode = prepared.code
1047
1324
  if (prepared.map) intermediateMaps.push(prepared.map)
@@ -1050,7 +1327,10 @@ export function vxrnCompilerPlugin(
1050
1327
  const compilerTarget =
1051
1328
  (babelOptions!.plugins![compilerPluginIndex] as any[])[1]?.target ?? '19'
1052
1329
  const compilerOut = await compiler.transformOxcReactCompiler(
1053
- id, curCode, compilerTarget, sourceMaps
1330
+ id,
1331
+ curCode,
1332
+ compilerTarget,
1333
+ sourceMaps
1054
1334
  )
1055
1335
  if (compilerOut?.code) {
1056
1336
  curCode = compilerOut.code
@@ -1541,7 +1821,10 @@ export function hermesCompatSWCPlugin(dev: boolean, sourceMaps = false): Plugin
1541
1821
  const err = result.errors[0]
1542
1822
  throw new Error(err.message + (err.codeframe ? `\n${err.codeframe}` : ''))
1543
1823
  }
1544
- output = { code: result.code, map: sourceMaps ? result.map : undefined }
1824
+ output = {
1825
+ code: result.code,
1826
+ map: sourceMaps ? result.map : undefined,
1827
+ }
1545
1828
  }
1546
1829
  const lowered = await transformHermesAsync(output?.code ?? code, id, sourceMaps)
1547
1830
  if (!lowered) return output
@@ -1581,7 +1864,8 @@ export function hermesLoopsPlugin(sourceMaps = false): Plugin {
1581
1864
  if (!result) return
1582
1865
 
1583
1866
  if (!sourceMaps) return { code: result.code, map: null }
1584
- if (result.maps.length === 1) return { code: result.code, map: result.maps[0] as any }
1867
+ if (result.maps.length === 1)
1868
+ return { code: result.code, map: result.maps[0] as any }
1585
1869
  const remapping = (await import('@jridgewell/remapping')).default
1586
1870
  return {
1587
1871
  code: result.code,
@@ -1785,9 +2069,44 @@ class ReactNativeDevRuntime extends BaseDevRuntime {
1785
2069
  }
1786
2070
  }
1787
2071
 
2072
+ reload() {
2073
+ var proxy = globalThis.__turboModuleProxy
2074
+ ? globalThis.__turboModuleProxy('DevSettings')
2075
+ : globalThis.nativeModuleProxy && globalThis.nativeModuleProxy.DevSettings;
2076
+ if (proxy && proxy.reload) proxy.reload();
2077
+ }
2078
+
1788
2079
  setup(socket) {
1789
2080
  if (this._socket) return;
1790
2081
  this._socket = socket;
2082
+ var runtime = this;
2083
+ // addEventListener rather than socket.onmessage: the bundle's WebSocket is
2084
+ // whatever the host platform provides, and a host that does not honor on*
2085
+ // property assignment would otherwise leave the app permanently without
2086
+ // Fast Refresh and say nothing.
2087
+ socket.addEventListener('message', function(event) {
2088
+ var message;
2089
+ try {
2090
+ message = JSON.parse(event.data);
2091
+ } catch (error) {
2092
+ return;
2093
+ }
2094
+ try {
2095
+ if (message.type === 'hmr:update') {
2096
+ // a patch that cannot be applied in place is the reload signal
2097
+ if (!runtime.applyHmrUpdate(message.code, message.changedIds, message.seq)) {
2098
+ runtime.reload();
2099
+ }
2100
+ } else if (message.type === 'hmr:reload') {
2101
+ runtime.reload();
2102
+ }
2103
+ } catch (error) {
2104
+ console.error('[vxrn HMR]: failed to apply update', error);
2105
+ }
2106
+ });
2107
+ socket.addEventListener('error', function(event) {
2108
+ console.warn('[vxrn HMR]: connection error', (event && event.message) || event);
2109
+ });
1791
2110
  }
1792
2111
  }
1793
2112