diff --git a/packages/core/lib/bundler/bundler.ts b/packages/core/lib/bundler/bundler.ts index 5f313f6d..76aed18a 100644 --- a/packages/core/lib/bundler/bundler.ts +++ b/packages/core/lib/bundler/bundler.ts @@ -7,6 +7,7 @@ import esbuild, { import invariant from 'invariant'; import ora from 'ora'; import { getGlobalVariables } from '@react-native-esbuild/internal'; +import { HmrTransformer } from '@react-native-esbuild/hmr'; import { setEnvironment, combineWithDefaultBundleOptions, @@ -27,7 +28,6 @@ import type { BundleResult, BundleRequestOptions, PluginContext, - UpdatedModule, ReportableEvent, ReactNativeEsbuildPluginCreator, } from '../types'; @@ -42,13 +42,14 @@ import { getResolveExtensionsOption, getLoaderOption, getEsbuildWebConfig, - getHmrUpdatedModule, + getHmrRuntimeTransformer, } from './helpers'; import { printLogo, printVersion } from './logo'; export class ReactNativeEsbuildBundler extends BundlerEventEmitter { public static caches = CacheStorage.getInstance(); public static shared = SharedStorage.getInstance(); + private static hmr = new Map(); private appLogger = new Logger('app', LogLevel.Trace); private buildTasks = new Map(); private plugins: ReactNativeEsbuildPluginCreator[] = []; @@ -228,8 +229,8 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { logLevel: 'silent', bundle: true, sourcemap: true, + metafile: true, minify: bundleOptions.minify, - metafile: bundleOptions.metafile, write: mode === 'bundle', ...webSpecifiedOptions, }; @@ -260,6 +261,8 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { data: { result: BuildResult; success: boolean }, context: PluginContext, ): void { + invariant(data.result.metafile, 'invalid metafile'); + /** * Exit at the end of a build in bundle mode. * @@ -270,9 +273,12 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { process.exit(1); } - const hmrSharedValue = ReactNativeEsbuildBundler.shared.get(context.id); + const hmrController = ReactNativeEsbuildBundler.hmr.get(context.id); + const sharedStorage = ReactNativeEsbuildBundler.shared.get(context.id); const currentTask = this.buildTasks.get(context.id); - invariant(hmrSharedValue, 'invalid hmr shared value'); + + invariant(sharedStorage, 'invalid shared storage'); + invariant(hmrController, 'no hmr controller'); invariant(currentTask, 'no task'); const bundleEndedAt = new Date(); @@ -280,7 +286,6 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { const bundleSourcemapFilename = `${bundleFilename}.map`; const revisionId = bundleEndedAt.getTime().toString(); const { outputFiles } = data.result; - let updatedModule: UpdatedModule | null = null; const findFromOutputFile = ( filename: string, @@ -300,12 +305,6 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { invariant(bundleOutput, 'empty bundle output'); invariant(bundleSourcemapOutput, 'empty sourcemap output'); - updatedModule = getHmrUpdatedModule( - hmrSharedValue.hmr.id, - hmrSharedValue.hmr.path, - bundleOutput.text, - ); - currentTask.handler?.resolver?.({ result: { source: bundleOutput.contents, @@ -319,21 +318,41 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { currentTask.handler?.rejecter?.(error); } finally { currentTask.status = 'resolved'; - this.emit('build-end', { - revisionId, - id: context.id, - additionalData: context.additionalData, - updatedModule, - }); + Promise.resolve( + sharedStorage.hmr.path + ? hmrController.getUpdates( + sharedStorage.hmr.path, + data.result.metafile, + ) + : null, + ) + .then((update) => { + this.emit('build-end', { + revisionId, + id: context.id, + additionalData: context.additionalData, + update, + }); + }) + .catch((error) => { + logger.error('unexpected error', error as Error); + this.emit('build-end', { + revisionId, + id: context.id, + additionalData: context.additionalData, + update: null, + }); + }); } } - private async getOrCreateBundleTask( + private async getOrSetupTask( bundleOptions: BundleOptions, additionalData?: BundlerAdditionalData, ): Promise { const targetTaskId = this.identifyTaskByBundleOptions(bundleOptions); + // Build Task if (!this.buildTasks.has(targetTaskId)) { logger.debug(`bundle task not registered (id: ${targetTaskId})`); const buildOptions = await this.getBuildOptionsForBundler( @@ -354,6 +373,22 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { logger.debug(`bundle task is now watching (id: ${targetTaskId})`); } + // HMR Controller + if (!ReactNativeEsbuildBundler.hmr.has(targetTaskId)) { + ReactNativeEsbuildBundler.hmr.set( + targetTaskId, + new HmrTransformer( + this.root, + getHmrRuntimeTransformer( + targetTaskId, + this.root, + this.config, + bundleOptions, + ), + ), + ); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Already `set()` if not exist. return this.buildTasks.get(targetTaskId)!; } @@ -440,7 +475,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { throw new Error('serve mode is only available on web platform'); } - const buildTask = await this.getOrCreateBundleTask( + const buildTask = await this.getOrSetupTask( combineWithDefaultBundleOptions(bundleOptions), additionalData, ); @@ -456,7 +491,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { additionalData?: BundlerAdditionalData, ): Promise { this.throwIfNotInitialized(); - const buildTask = await this.getOrCreateBundleTask( + const buildTask = await this.getOrSetupTask( combineWithDefaultBundleOptions(bundleOptions), additionalData, ); diff --git a/packages/core/lib/bundler/events/index.ts b/packages/core/lib/bundler/events/index.ts index 5c10747e..27473fe6 100644 --- a/packages/core/lib/bundler/events/index.ts +++ b/packages/core/lib/bundler/events/index.ts @@ -1,9 +1,9 @@ import EventEmitter from 'node:events'; +import type { BundleUpdate } from '@react-native-esbuild/hmr'; import type { BundlerAdditionalData, BuildStatus, ReportableEvent, - UpdatedModule, } from '../../types'; export class BundlerEventEmitter extends EventEmitter { @@ -47,7 +47,7 @@ export interface BundlerEventPayload { 'build-end': { id: number; revisionId: string; - updatedModule: UpdatedModule | null; + update: BundleUpdate | null; additionalData?: BundlerAdditionalData; }; 'build-status-change': BuildStatus & { diff --git a/packages/core/lib/bundler/helpers/hmr.ts b/packages/core/lib/bundler/helpers/hmr.ts index 5459f90d..e3255aa3 100644 --- a/packages/core/lib/bundler/helpers/hmr.ts +++ b/packages/core/lib/bundler/helpers/hmr.ts @@ -1,25 +1,48 @@ import { - getModuleCodeFromBundle, - isReactRefreshRegistered, -} from '@react-native-esbuild/hmr'; -import type { UpdatedModule } from '../../types'; + AsyncTransformPipeline, + swcPresets, +} from '@react-native-esbuild/transformer'; +import type { BundleOptions } from '@react-native-esbuild/config'; +import type { Config } from '../../types'; -export const getHmrUpdatedModule = ( - id: string | null, - path: string | null, - bundleCode: string, -): UpdatedModule | null => { - const updatedCode = - id && path ? getModuleCodeFromBundle(bundleCode, id) : null; +const DUMMY_ESBUILD_ARGS = { + namespace: '', + suffix: '', + pluginData: undefined, +} as const; - return updatedCode - ? { - code: updatedCode, - id: id ?? '', - path: path ?? '', - mode: isReactRefreshRegistered(updatedCode) - ? 'hot-reload' - : 'full-reload', - } - : null; +export const getHmrRuntimeTransformer = ( + id: number, + root: string, + config: Config, + bundleOptions: BundleOptions, +): ((code: string, path: string) => Promise) => { + const { + stripFlowPackageNames = [], + fullyTransformPackageNames = [], + additionalTransformRules, + } = config.transformer ?? {}; + const additionalBabelRules = additionalTransformRules?.babel ?? []; + const additionalSwcRules = additionalTransformRules?.swc ?? []; + + const transformPipeline = new AsyncTransformPipeline.builder({ + ...bundleOptions, + id, + root, + }) + .setSwcPreset(swcPresets.getReactNativeRuntimePreset()) + .setFullyTransformPackages(fullyTransformPackageNames) + .setStripFlowPackages(stripFlowPackageNames) + .setAdditionalBabelTransformRules(additionalBabelRules) + .setAdditionalSwcTransformRules(additionalSwcRules) + .build(); + + return (code: string, path: string) => { + return transformPipeline + .transform(code, { + ...DUMMY_ESBUILD_ARGS, + path, + }) + .then((result) => result.code); + }; }; diff --git a/packages/core/lib/bundler/plugins/metafilePlugin/metafilePlugin.ts b/packages/core/lib/bundler/plugins/metafilePlugin/metafilePlugin.ts index 70bb8c1b..96e17ab1 100644 --- a/packages/core/lib/bundler/plugins/metafilePlugin/metafilePlugin.ts +++ b/packages/core/lib/bundler/plugins/metafilePlugin/metafilePlugin.ts @@ -12,18 +12,16 @@ export const createMetafilePlugin: ReactNativeEsbuildPluginCreator = ( name: NAME, setup: (build): void => { build.onEnd(async (result: BuildResult) => { - const { metafile } = result; + if (!(context.metafile && result.metafile)) return; + const filename = path.join( context.root, `metafile-${context.platform}-${new Date().getTime().toString()}.json`, ); - - if (metafile) { - logger.debug('writing esbuild metafile', { destination: filename }); - await fs.writeFile(filename, JSON.stringify(metafile), { - encoding: 'utf-8', - }); - } + logger.debug('writing esbuild metafile', { destination: filename }); + await fs.writeFile(filename, JSON.stringify(result.metafile), { + encoding: 'utf-8', + }); }); }, }); diff --git a/packages/core/lib/types.ts b/packages/core/lib/types.ts index 04be84d3..f997c69c 100644 --- a/packages/core/lib/types.ts +++ b/packages/core/lib/types.ts @@ -173,12 +173,6 @@ export interface BundlerSharedData { } export type BundlerAdditionalData = Record; -export interface UpdatedModule { - id: string; - path: string; - code: string; - mode: 'hot-reload' | 'full-reload'; -} export interface PluginContext extends BundleOptions { id: number; diff --git a/packages/dev-server/lib/server/ReactNativeAppServer.ts b/packages/dev-server/lib/server/ReactNativeAppServer.ts index f6a15481..15ac00e7 100644 --- a/packages/dev-server/lib/server/ReactNativeAppServer.ts +++ b/packages/dev-server/lib/server/ReactNativeAppServer.ts @@ -147,18 +147,15 @@ export class ReactNativeAppServer extends DevServer { }; this.bundler.on('build-start', hmr.updateStart); - this.bundler.on( - 'build-end', - ({ revisionId, updatedModule, additionalData }) => { - // `additionalData` can be `{ disableRefresh: true }` by `serve-asset-middleware`. - if (!additionalData?.disableRefresh) { - updatedModule?.mode === 'hot-reload' - ? hmr.hotReload(revisionId, updatedModule.code) - : hmr.liveReload(revisionId); - } - hmr.updateDone(); - }, - ); + this.bundler.on('build-end', ({ revisionId, update, additionalData }) => { + // `additionalData` can be `{ disableRefresh: true }` by `serve-asset-middleware`. + if (!additionalData?.disableRefresh) { + update?.fullyReload + ? hmr.liveReload(revisionId) + : hmr.hotReload(revisionId, update.code); + } + hmr.updateDone(); + }); this.server.on('upgrade', (request, socket, head) => { if (!request.url) return; diff --git a/packages/dev-server/lib/server/ReactNativeWebServer.ts b/packages/dev-server/lib/server/ReactNativeWebServer.ts index f727e3e1..d6bd4015 100644 --- a/packages/dev-server/lib/server/ReactNativeWebServer.ts +++ b/packages/dev-server/lib/server/ReactNativeWebServer.ts @@ -102,16 +102,13 @@ export class ReactNativeWebServer extends DevServer { const { server: hmrServer, ...hmr } = createHmrMiddlewareForWeb(); - this.bundler.on( - 'build-end', - ({ revisionId, updatedModule, additionalData }) => { - if (!additionalData?.disableRefresh) { - updatedModule?.mode === 'hot-reload' - ? hmr.hotReload(revisionId, updatedModule.code) - : hmr.liveReload(revisionId); - } - }, - ); + this.bundler.on('build-end', ({ revisionId, update, additionalData }) => { + if (!additionalData?.disableRefresh) { + update?.fullyReload + ? hmr.liveReload(revisionId) + : hmr.hotReload(revisionId, update.code); + } + }); const symbolicateMiddleware = createSymbolicateMiddleware( { bundler, devServerOptions: this.devServerOptions }, diff --git a/packages/hmr/lib/HmrTransformer.ts b/packages/hmr/lib/HmrTransformer.ts new file mode 100644 index 00000000..95bd27ac --- /dev/null +++ b/packages/hmr/lib/HmrTransformer.ts @@ -0,0 +1,116 @@ +import fs from 'node:fs/promises'; +import type { Metafile } from 'esbuild'; +import { getReloadByDevSettingsProxy } from '@react-native-esbuild/internal'; +import { logger } from './shared'; +import type { BundleMeta, BundleUpdate } from './types'; + +export class HmrTransformer { + constructor( + private root: string, + private transformer: (code: string, path: string) => Promise, + ) {} + + public async getUpdates( + modulePath: string, + metafile: Metafile, + ): Promise { + try { + const code = await this.transformRuntime(modulePath, metafile); + return { + id: '', + code: this.wrapSafetyWithReload(code), + path: modulePath, + fullyReload: false, + }; + } catch (error) { + logger.error('unable to get updates', error as Error); + return { + id: '', + code: '', + path: modulePath, + fullyReload: true, + }; + } + } + + private async transformRuntime( + modulePath: string, + metafile: Metafile, + ): Promise { + // Indexing parent modules. + const bundleMeta = this.createBundleMeta(metafile); + const reverseDependencies = this.getReverseDependencies( + modulePath, + bundleMeta, + ); + + const transformedCodes = await Promise.all( + [modulePath, ...reverseDependencies].map(async (modulePath) => { + const rawCode = await fs.readFile(modulePath, 'utf-8'); + const code = await this.transformer(rawCode, modulePath); + return code; + }), + ); + + return transformedCodes.join('\n'); + } + + /** + * Enhance `esbuild.Metafile` to `BundleMeta`. + * + * Add `parents` property into each module info. + * This property will be used as indexed value for explore parent modules. + */ + private createBundleMeta(metafile: Metafile): BundleMeta { + const bundleMeta = metafile as BundleMeta; + Object.entries(bundleMeta.inputs).forEach(([filename, moduleInfo]) => { + moduleInfo.imports.forEach(({ path }) => { + const moduleInfo = bundleMeta.inputs[path]; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- can be `undefined`. + if (!moduleInfo) { + logger.warn(`${path} is not exist in metafile`); + return; + } + + if (!bundleMeta.inputs[path].parents) { + bundleMeta.inputs[path].parents = new Set(); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- after if statement. + bundleMeta.inputs[path].parents!.add(filename); + }); + }); + return bundleMeta; + } + + private getReverseDependencies( + modulePath: string, + bundleMeta: BundleMeta, + dependencies: string[] = [], + depth = 0, + ): string[] { + if (bundleMeta.inputs[modulePath].parents) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- after if statement. + bundleMeta.inputs[modulePath].parents!.forEach((parentModule) => { + const line = new Array(depth * 2).fill('-').join(''); + logger.debug(`+--${line} ${parentModule}`); + + dependencies = this.getReverseDependencies(parentModule, bundleMeta, [ + ...dependencies, + parentModule, + ]); + }); + } + return dependencies; + } + + private wrapSafetyWithReload(code: string): string { + return `try { + ${code} + } catch (error) { + console.error('[HMR] unable to accept updated module'); + ${getReloadByDevSettingsProxy()} + }`; + } +} diff --git a/packages/hmr/lib/index.ts b/packages/hmr/lib/index.ts index c7b0888a..455b3262 100644 --- a/packages/hmr/lib/index.ts +++ b/packages/hmr/lib/index.ts @@ -54,5 +54,6 @@ export const isReactRefreshRegistered = (code: string): boolean => export * from './server'; export * from './constants'; +export { HmrTransformer } from './HmrTransformer'; export type * from './types'; export type { HmrServer } from './server/HmrServer'; diff --git a/packages/hmr/lib/types.ts b/packages/hmr/lib/types.ts index c152559f..b7846eff 100644 --- a/packages/hmr/lib/types.ts +++ b/packages/hmr/lib/types.ts @@ -1,3 +1,4 @@ +import type { Metafile, ImportKind } from 'esbuild'; import type * as RefreshRuntime from 'react-refresh/runtime'; /* eslint-disable no-var -- allow */ @@ -84,3 +85,28 @@ export interface HmrModule { sourceMappingURL: string | null; sourceURL: string | null; } + +export type BundleMeta = Metafile & { + inputs: Metafile['inputs'] & + Record< + string, + { + bytes: number; + imports: { + path: string; + kind: ImportKind; + external?: boolean; + original?: string; + }[]; + parents?: Set; + format?: 'cjs' | 'esm'; + } + >; +}; + +export interface BundleUpdate { + id: string; + path: string; + code: string; + fullyReload: boolean; +} diff --git a/packages/hmr/package.json b/packages/hmr/package.json index fda733a3..588d16b5 100644 --- a/packages/hmr/package.json +++ b/packages/hmr/package.json @@ -48,6 +48,7 @@ "esbuild": "^0.19.3" }, "dependencies": { + "@react-native-esbuild/internal": "workspace:^", "@react-native-esbuild/utils": "workspace:*", "react-refresh": "^0.14.0", "ws": "^8.14.2" diff --git a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts index 05e10c33..c3b195cd 100644 --- a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts +++ b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts @@ -120,8 +120,7 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr }; }; - let transformPipeline: AsyncTransformPipeline; - const transformPipelineBuilder = new AsyncTransformPipeline.builder(context) + const transformPipeline = new AsyncTransformPipeline.builder(context) .setSwcPreset(swcPresets.getReactNativeRuntimePreset()) .setInjectScripts(injectScriptPaths) .setFullyTransformPackages(fullyTransformPackageNames) @@ -129,11 +128,8 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr .setAdditionalBabelTransformRules(additionalBabelRules) .setAdditionalSwcTransformRules(additionalSwcRules) .onStart(onBeforeTransform) - .onEnd(onAfterTransform); - - build.onStart(() => { - transformPipeline = transformPipelineBuilder.build(); - }); + .onEnd(onAfterTransform) + .build(); build.onLoad({ filter: /\.(?:[mc]js|[tj]sx?)$/ }, async (args) => { const rawCode = await fs.readFile(args.path, { encoding: 'utf-8' }); diff --git a/yarn.lock b/yarn.lock index 6e2c4b2e..d8c7816b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4379,6 +4379,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-native-esbuild/hmr@workspace:packages/hmr" dependencies: + "@react-native-esbuild/internal": "workspace:^" "@react-native-esbuild/utils": "workspace:*" "@swc/helpers": ^0.5.2 "@types/react-refresh": ^0.14.3 @@ -4388,7 +4389,7 @@ __metadata: languageName: unknown linkType: soft -"@react-native-esbuild/internal@workspace:*, @react-native-esbuild/internal@workspace:packages/internal": +"@react-native-esbuild/internal@workspace:*, @react-native-esbuild/internal@workspace:^, @react-native-esbuild/internal@workspace:packages/internal": version: 0.0.0-use.local resolution: "@react-native-esbuild/internal@workspace:packages/internal" dependencies: