diff --git a/.eslintrc.js b/.eslintrc.js index bf4642bb..5edc3d8f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -51,6 +51,17 @@ module.exports = { 'no-console': 'off', }, }, + { + files: ['packages/hmr/lib/runtime/*.ts'], + rules: { + 'no-console': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + }, + }, { files: ['packages/jest/lib/**/*.ts'], rules: { diff --git a/.yarn/cache/@types-react-refresh-npm-0.14.3-d201a15407-ff48c18f92.zip b/.yarn/cache/@types-react-refresh-npm-0.14.3-d201a15407-ff48c18f92.zip new file mode 100644 index 00000000..6bf799ac Binary files /dev/null and b/.yarn/cache/@types-react-refresh-npm-0.14.3-d201a15407-ff48c18f92.zip differ diff --git a/.yarn/cache/swc-plugin-global-module-npm-0.1.0-alpha.5-cd339b699a-10523c5464.zip b/.yarn/cache/swc-plugin-global-module-npm-0.1.0-alpha.5-cd339b699a-10523c5464.zip new file mode 100644 index 00000000..49c0da61 Binary files /dev/null and b/.yarn/cache/swc-plugin-global-module-npm-0.1.0-alpha.5-cd339b699a-10523c5464.zip differ diff --git a/README.md b/README.md index 0f40b9ff..4168443b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - 💾 In-memory & Local File System Caching - 🎨 Flexible & Extensible - 🔥 Supports JSC & Hermes Runtime -- 🔄 Supports Live Reload +- 🔄 Supports HMR & Live Reload - 🐛 Supports Debugging(Flipper, Chrome Debugger) - 🌍 Supports All Platforms(Android, iOS, Web) - ✨ New Architecture Ready diff --git a/docs/pages/configuration/basic-configuration.mdx b/docs/pages/configuration/basic-configuration.mdx index 8c5d10b8..c135d96a 100644 --- a/docs/pages/configuration/basic-configuration.mdx +++ b/docs/pages/configuration/basic-configuration.mdx @@ -16,6 +16,9 @@ exports.default = {}; By default, follow the configuration below. ```js +/** + * @type {import('@react-native-esbuild/core').Config} + */ exports.default = { cache: true, logger: { @@ -28,13 +31,6 @@ exports.default = { assetExtensions: [/* internal/lib/defaults.ts */], }, transformer: { - jsc: { - transform: { - react: { - runtime: 'automatic', - }, - }, - }, stripFlowPackageNames: ['react-native'], }, web: { @@ -68,7 +64,6 @@ Resolver configurations. Transformer configurations. -- `transformer.jsc`: [jsc](https://swc.rs/docs/configuration/compilation) config in swc. - `transformer.stripFlowPackageNames`: Package names to strip flow syntax from (Defaults to `['react-native']`) - `transformer.fullyTransformPackageNames`: Package names to fully transform with [metro-react-native-babel-preset](https://github.com/facebook/react-native/tree/main/packages/react-native-babel-preset) from - `transformer.additionalTransformRules`: Additional transform rules. This rules will be applied before phase of transform to es5 @@ -90,6 +85,15 @@ Additional Esbuild plugins. For more details, go to [Custom Plugins](/configuration/custom-plugins) +### experimental + + + Experimental configurations. + + +- `experimental.hmr`: Enable HMR(Hot Module Replacement) on development mode. (Defaults to `false`) + - For more details and limitations, go to [Hot Module Replacement](/limitations/hot-module-replacement). + ## Types
@@ -209,6 +213,17 @@ interface Config { * Additional Esbuild plugins. */ plugins?: EsbuildPlugin[]; + /** + * Experimental configurations + */ + experimental?: { + /** + * Enable HMR(Hot Module Replacement) on development mode. + * + * Defaults to `false`. + */ + hmr?: boolean; + }; /** * Client event receiver */ diff --git a/docs/pages/getting-started/installation.md b/docs/pages/getting-started/installation.md index 530dc5de..78e783a7 100644 --- a/docs/pages/getting-started/installation.md +++ b/docs/pages/getting-started/installation.md @@ -31,7 +31,7 @@ And create `react-native-esbuild.js` to project root. exports.default = {}; ``` -for more details, go to [Configuration](/configuration/basic). +for more details, go to [Basic Configuration](/configuration/basic-configuration). ## Native Setup diff --git a/docs/pages/limitations/hot-module-replacement.md b/docs/pages/limitations/hot-module-replacement.md deleted file mode 100644 index 9cdb6133..00000000 --- a/docs/pages/limitations/hot-module-replacement.md +++ /dev/null @@ -1,5 +0,0 @@ -# Hot Module Replacement - -Esbuild doesn't currently support Hot Module Replacement(HMR). - -Metro is implementing HMR capabilities based on [react-refresh](https://www.npmjs.com/package/react-refresh). I'll be looking at working with this, but as it is one of the complex implementations, unfortunately not sure when it will be available. diff --git a/docs/pages/limitations/hot-module-replacement.mdx b/docs/pages/limitations/hot-module-replacement.mdx new file mode 100644 index 00000000..a494cd39 --- /dev/null +++ b/docs/pages/limitations/hot-module-replacement.mdx @@ -0,0 +1,30 @@ +import { Callout } from 'nextra/components' + +# Hot Module Replacement + + + HMR(Hot Module Replacement) is experimental. + + +esbuild doesn't currently support Hot Module Replacement(HMR). + +So, I working hard for implement custom HMR and it's partially available as an experimental feature. + +You can enable HMR by `experimental.hmr` set to `true` in your configuration file. + +```js +/** + * @type {import('@react-native-esbuild/core').Config} + */ +exports.default = { + // ... + experimental: { + hmr: true, + }, +}; +``` + +and here are some limitations. + +- Detects changes in the `/*` only. +- Changes detected in `/node_modules/*` will be ignored and fully refreshed after rebuild. diff --git a/example/.gitignore b/example/.gitignore index f4c3ff65..89fdaefa 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -73,6 +73,9 @@ yarn-error.log !.yarn/sdks !.yarn/versions +# @swc +.swc + # @react-native-esbuild .rne .swc diff --git a/example/.swc/plugins/v7_macos_aarch64_0.104.4/90b69c1e7164e0dc62ebf083a345c7d5b7b0450c10b6dada84547644da60c783 b/example/.swc/plugins/v7_macos_aarch64_0.104.4/90b69c1e7164e0dc62ebf083a345c7d5b7b0450c10b6dada84547644da60c783 deleted file mode 100644 index d5283fd2..00000000 Binary files a/example/.swc/plugins/v7_macos_aarch64_0.104.4/90b69c1e7164e0dc62ebf083a345c7d5b7b0450c10b6dada84547644da60c783 and /dev/null differ diff --git a/example/react-native-esbuild.config.js b/example/react-native-esbuild.config.js index d5655063..9547c723 100644 --- a/example/react-native-esbuild.config.js +++ b/example/react-native-esbuild.config.js @@ -29,4 +29,7 @@ exports.default = { ], }, }, + experimental: { + hmr: true, + }, }; diff --git a/packages/core/lib/bundler/bundler.ts b/packages/core/lib/bundler/bundler.ts index 5146daa3..7dc610f6 100644 --- a/packages/core/lib/bundler/bundler.ts +++ b/packages/core/lib/bundler/bundler.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import type { Stats } from 'node:fs'; import esbuild, { type BuildOptions, type BuildResult, @@ -7,6 +8,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, @@ -35,23 +37,28 @@ import { createBuildStatusPlugin, createMetafilePlugin } from './plugins'; import { BundlerEventEmitter } from './events'; import { loadConfig, - getConfigFromGlobal, createPromiseHandler, + getConfigFromGlobal, getTransformedPreludeScript, getResolveExtensionsOption, getLoaderOption, getEsbuildWebConfig, + getExternalFromPackageJson, + getExternalModulePattern, } from './helpers'; import { printLogo, printVersion } from './logo'; export class ReactNativeEsbuildBundler extends BundlerEventEmitter { - public static caches = new CacheStorage(); - public static shared = new SharedStorage(); + 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[] = []; - private initialized = false; private config: Config; + private external: string[]; + private externalPattern: string; + private initialized = false; /** * Must be bootstrapped first at the entry point @@ -98,6 +105,11 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { constructor(private root: string = process.cwd()) { super(); this.config = getConfigFromGlobal(); + this.external = getExternalFromPackageJson(root); + this.externalPattern = getExternalModulePattern( + this.external, + this.config.resolver?.assetExtensions ?? [], + ); this.on('report', (event) => { this.broadcastToReporter(event); }); @@ -131,19 +143,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { private startWatcher(): Promise { return FileSystemWatcher.getInstance() - .setHandler((event, changedFile, stats) => { - const hasTask = this.buildTasks.size > 0; - ReactNativeEsbuildBundler.shared.setValue({ - watcher: { - changed: hasTask && event === 'change' ? changedFile : null, - stats, - }, - }); - - for (const { context, handler } of this.buildTasks.values()) { - context.rebuild().catch((error) => handler?.rejecter?.(error)); - } - }) + .setHandler(this.handleFileChanged.bind(this)) .watch(this.root); } @@ -156,11 +156,13 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { invariant(config.resolver, 'invalid resolver configuration'); invariant(config.resolver.mainFields, 'invalid mainFields'); invariant(config.transformer, 'invalid transformer configuration'); - invariant(config.resolver.assetExtensions, 'invalid assetExtension'); + invariant(config.resolver.assetExtensions, 'invalid assetExtensions'); invariant(config.resolver.sourceExtensions, 'invalid sourceExtensions'); - setEnvironment(bundleOptions.dev); + const enableHmr = Boolean( + mode === 'watch' && bundleOptions.dev && config.experimental?.hmr, + ); const webSpecifiedOptions = bundleOptions.platform === 'web' ? getEsbuildWebConfig(mode, this.root, bundleOptions) @@ -176,6 +178,8 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { id: this.identifyTaskByBundleOptions(bundleOptions), root: this.root, config: this.config, + externalPattern: this.externalPattern, + enableHmr, mode, additionalData, }; @@ -183,7 +187,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { return { entryPoints: [bundleOptions.entry], outfile: bundleOptions.outfile, - sourceRoot: path.dirname(bundleOptions.entry), + sourceRoot: this.root, mainFields: config.resolver.mainFields, resolveExtensions: getResolveExtensionsOption( bundleOptions, @@ -193,7 +197,13 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { loader: getLoaderOption(config.resolver.assetExtensions), define: getGlobalVariables(bundleOptions), banner: { - js: await getTransformedPreludeScript(bundleOptions, this.root), + js: await getTransformedPreludeScript( + bundleOptions, + this.root, + [enableHmr ? 'swc-plugin-global-module/runtime' : undefined].filter( + Boolean, + ) as string[], + ), }, plugins: [ createBuildStatusPlugin(context, { @@ -207,7 +217,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { // Additional plugins in configuration. ...(config.plugins ?? []), ], - legalComments: bundleOptions.dev ? 'inline' : 'none', + legalComments: 'none', target: 'es6', format: 'esm', supported: { @@ -225,8 +235,8 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { logLevel: 'silent', bundle: true, sourcemap: true, + metafile: true, minify: bundleOptions.minify, - metafile: bundleOptions.metafile, write: mode === 'bundle', ...webSpecifiedOptions, }; @@ -257,6 +267,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. * @@ -267,8 +279,19 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { process.exit(1); } + const sharedStorage = ReactNativeEsbuildBundler.shared.get(context.id); + const hmrController = ReactNativeEsbuildBundler.hmr.get(context.id); const currentTask = this.buildTasks.get(context.id); + invariant(sharedStorage, 'invalid shared storage'); invariant(currentTask, 'no task'); + + if (context.enableHmr) { + invariant(hmrController, 'no hmr controller'); + ReactNativeEsbuildBundler.shared.setValue({ + bundleMeta: HmrTransformer.createBundleMeta(data.result.metafile), + }); + } + const bundleEndedAt = new Date(); const bundleFilename = context.outfile; const bundleSourcemapFilename = `${bundleFilename}.map`; @@ -310,16 +333,53 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { revisionId, id: context.id, additionalData: context.additionalData, + update: null, + }); + } + } + + private handleFileChanged( + event: string, + changedFile: string, + _stats?: Stats, + ): void { + const hasTask = this.buildTasks.size > 0; + const isChanged = event === 'change'; + if (!(hasTask && isChanged)) return; + + if ( + this.config.experimental?.hmr && + HmrTransformer.isBoundary(changedFile) + ) { + for (const [ + id, + hmrController, + ] of ReactNativeEsbuildBundler.hmr.entries()) { + const { bundleMeta } = ReactNativeEsbuildBundler.shared.get(id); + Promise.resolve( + bundleMeta ? hmrController.getDelta(changedFile, bundleMeta) : null, + ).then((update) => { + this.emit('build-end', { + id, + update, + revisionId: new Date().getTime().toString(), + }); + }); + } + } else { + this.buildTasks.forEach(({ context }) => { + context.rebuild(); }); } } - 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( @@ -340,6 +400,35 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { logger.debug(`bundle task is now watching (id: ${targetTaskId})`); } + // HMR Transformer + if ( + this.config.experimental?.hmr && + !ReactNativeEsbuildBundler.hmr.has(targetTaskId) + ) { + const { + stripFlowPackageNames, + fullyTransformPackageNames, + additionalTransformRules, + } = this.config.transformer ?? {}; + ReactNativeEsbuildBundler.hmr.set( + targetTaskId, + new HmrTransformer( + { + ...bundleOptions, + id: targetTaskId, + root: this.root, + externalPattern: this.externalPattern, + }, + { + additionalBabelRules: additionalTransformRules?.babel, + additionalSwcRules: additionalTransformRules?.swc, + fullyTransformPackageNames, + stripFlowPackageNames, + }, + ), + ); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Already `set()` if not exist. return this.buildTasks.get(targetTaskId)!; } @@ -426,7 +515,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, ); @@ -442,7 +531,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 cd3718b7..27473fe6 100644 --- a/packages/core/lib/bundler/events/index.ts +++ b/packages/core/lib/bundler/events/index.ts @@ -1,4 +1,5 @@ import EventEmitter from 'node:events'; +import type { BundleUpdate } from '@react-native-esbuild/hmr'; import type { BundlerAdditionalData, BuildStatus, @@ -46,6 +47,7 @@ export interface BundlerEventPayload { 'build-end': { id: number; revisionId: string; + update: BundleUpdate | null; additionalData?: BundlerAdditionalData; }; 'build-status-change': BuildStatus & { diff --git a/packages/core/lib/bundler/helpers/config.ts b/packages/core/lib/bundler/helpers/config.ts index 8c83fd43..c04ce8e3 100644 --- a/packages/core/lib/bundler/helpers/config.ts +++ b/packages/core/lib/bundler/helpers/config.ts @@ -29,13 +29,6 @@ export const loadConfig = (configFilePath?: string): Config => { assetExtensions: ASSET_EXTENSIONS, }, transformer: { - jsc: { - transform: { - react: { - runtime: 'automatic', - }, - }, - }, stripFlowPackageNames: ['react-native'], }, web: { diff --git a/packages/core/lib/bundler/helpers/fs.ts b/packages/core/lib/bundler/helpers/fs.ts new file mode 100644 index 00000000..2c774537 --- /dev/null +++ b/packages/core/lib/bundler/helpers/fs.ts @@ -0,0 +1,14 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const getExternalFromPackageJson = (root: string): string[] => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- allow any. + const { dependencies = {} } = JSON.parse( + fs.readFileSync(path.join(root, 'package.json'), 'utf-8'), + ); + return [ + 'react/jsx-runtime', + '@react-navigation/devtools', + ...Object.keys(dependencies as Record), + ]; +}; diff --git a/packages/core/lib/bundler/helpers/index.ts b/packages/core/lib/bundler/helpers/index.ts index dd49165c..ad840df1 100644 --- a/packages/core/lib/bundler/helpers/index.ts +++ b/packages/core/lib/bundler/helpers/index.ts @@ -1,3 +1,4 @@ export * from './async'; export * from './config'; +export * from './fs'; export * from './internal'; diff --git a/packages/core/lib/bundler/helpers/internal.ts b/packages/core/lib/bundler/helpers/internal.ts index 5955e7f3..eccde4d7 100644 --- a/packages/core/lib/bundler/helpers/internal.ts +++ b/packages/core/lib/bundler/helpers/internal.ts @@ -1,16 +1,21 @@ +import fs from 'node:fs/promises'; import type { BuildOptions } from 'esbuild'; import { getPreludeScript } from '@react-native-esbuild/internal'; import type { TransformerContext } from '@react-native-esbuild/transformer'; import { stripFlowWithSucrase, - minifyWithSwc, + transformWithSwc, swcPresets, } from '@react-native-esbuild/transformer'; import type { BundleOptions } from '@react-native-esbuild/config'; +const loadScript = (path: string): Promise => + fs.readFile(require.resolve(path), 'utf-8'); + export const getTransformedPreludeScript = async ( bundleOptions: BundleOptions, root: string, + additionalScriptPaths?: string[], ): Promise => { // Dummy context const context: TransformerContext = { @@ -20,19 +25,28 @@ export const getTransformedPreludeScript = async ( dev: bundleOptions.dev, entry: bundleOptions.entry, }; - const preludeScript = await getPreludeScript(bundleOptions, root); + + const additionalPreludeScripts = await Promise.all( + (additionalScriptPaths ?? []).map(loadScript), + ); + + const preludeScript = [ + await getPreludeScript(bundleOptions, root), + ...additionalPreludeScripts, + ].join('\n'); /** * Remove `"use strict";` added by sucrase. * @see {@link https://github.com/alangpierce/sucrase/issues/787#issuecomment-1483934492} */ - const strippedScript = stripFlowWithSucrase(preludeScript, context) + const strippedScript = stripFlowWithSucrase(preludeScript, { context }) .replace(/"use strict";/, '') .trim(); - return bundleOptions.minify - ? minifyWithSwc(strippedScript, context, swcPresets.getMinifyPreset()) - : strippedScript; + return transformWithSwc(strippedScript, { + context, + preset: swcPresets.getMinifyPreset({ minify: bundleOptions.minify }), + }); }; export const getResolveExtensionsOption = ( @@ -70,3 +84,24 @@ export const getLoaderOption = ( assetExtensions.map((ext) => [ext, 'file'] as const), ); }; + +export const getExternalModulePattern = ( + externalPackages: string[], + assetExtensions: string[], +): string => { + const externalPackagePatterns = externalPackages + .map((packageName) => `^${packageName}/?$`) + .join('|'); + + const assetPatterns = [ + ...assetExtensions, + // `.svg` assets will be handled by `svg-transform-plugin`. + '.svg', + // `.json` contents will be handled by `react-native-runtime-transform-plugin`. + '.json', + ] + .map((extension) => `${extension}$`) + .join('|'); + + return `(${externalPackagePatterns}|${assetPatterns})`; +}; 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/bundler/plugins/statusPlugin/StatusLogger.ts b/packages/core/lib/bundler/plugins/statusPlugin/StatusLogger.ts index 0a8c4384..15d5cd02 100644 --- a/packages/core/lib/bundler/plugins/statusPlugin/StatusLogger.ts +++ b/packages/core/lib/bundler/plugins/statusPlugin/StatusLogger.ts @@ -7,10 +7,16 @@ import { getBuildStatusCachePath } from '@react-native-esbuild/config'; import { colors, isTTY } from '@react-native-esbuild/utils'; import { logger } from '../../../shared'; import { ESBUILD_LABEL } from '../../logo'; -import type { BuildStatus, PluginContext } from '../../../types'; +import type { + BuildStatus, + BundlerSharedData, + PluginContext, +} from '../../../types'; +import { SharedStorage } from '../../storages'; import { fromTemplate, getSummaryTemplate } from './templates'; export class StatusLogger { + private bundlerSharedData: BundlerSharedData; private platformText: string; private spinner: Ora; private totalModuleCount = 0; @@ -19,6 +25,7 @@ export class StatusLogger { private previousPercent = 0; constructor(private context: PluginContext) { + this.bundlerSharedData = SharedStorage.getInstance().get(context.id); this.platformText = colors.gray( `[${[context.platform, context.dev ? 'dev' : null] .filter(Boolean) @@ -103,6 +110,7 @@ export class StatusLogger { this.previousPercent = 0; this.statusUpdate(); + process.stdout.write('\n'); isTTY() ? this.spinner.start() : this.print(`${this.platformText} build in progress...`); diff --git a/packages/core/lib/bundler/storages/CacheStorage.ts b/packages/core/lib/bundler/storages/CacheStorage.ts index c62a94c4..b0811218 100644 --- a/packages/core/lib/bundler/storages/CacheStorage.ts +++ b/packages/core/lib/bundler/storages/CacheStorage.ts @@ -9,7 +9,16 @@ import { Storage } from './Storage'; const CACHE_DIRECTORY = path.join(os.tmpdir(), GLOBAL_CACHE_DIR); export class CacheStorage extends Storage { - constructor() { + private static instance: CacheStorage | null = null; + + public static getInstance(): CacheStorage { + if (CacheStorage.instance === null) { + CacheStorage.instance = new CacheStorage(); + } + return CacheStorage.instance; + } + + private constructor() { super(); try { fs.accessSync(CACHE_DIRECTORY, fs.constants.R_OK | fs.constants.W_OK); diff --git a/packages/core/lib/bundler/storages/SharedStorage.ts b/packages/core/lib/bundler/storages/SharedStorage.ts index 05c2389e..d2f77a40 100644 --- a/packages/core/lib/bundler/storages/SharedStorage.ts +++ b/packages/core/lib/bundler/storages/SharedStorage.ts @@ -2,13 +2,21 @@ import type { BundlerSharedData } from '../../types'; import { Storage } from './Storage'; export class SharedStorage extends Storage { + private static instance: SharedStorage | null = null; + + public static getInstance(): SharedStorage { + if (SharedStorage.instance === null) { + SharedStorage.instance = new SharedStorage(); + } + return SharedStorage.instance; + } + + private constructor() { + super(); + } + private getDefaultSharedData(): BundlerSharedData { - return { - watcher: { - changed: null, - stats: undefined, - }, - }; + return { bundleMeta: undefined }; } public get(key: number): BundlerSharedData { @@ -25,17 +33,13 @@ export class SharedStorage extends Storage { public setValue(value: Partial): void { for (const sharedData of this.data.values()) { - sharedData.watcher.changed = - value.watcher?.changed ?? sharedData.watcher.changed; - sharedData.watcher.stats = - value.watcher?.stats ?? sharedData.watcher.stats; + sharedData.bundleMeta = value.bundleMeta; } } public clearAll(): Promise { for (const sharedData of this.data.values()) { - sharedData.watcher.changed = null; - sharedData.watcher.stats = undefined; + sharedData.bundleMeta = undefined; } return Promise.resolve(); } diff --git a/packages/core/lib/types.ts b/packages/core/lib/types.ts index 139eb200..21c5e762 100644 --- a/packages/core/lib/types.ts +++ b/packages/core/lib/types.ts @@ -1,11 +1,10 @@ -import type { Stats } from 'node:fs'; import type { BuildContext, Plugin } from 'esbuild'; import type { BabelTransformRule, SwcTransformRule, } from '@react-native-esbuild/transformer'; +import type { BundleMeta } from '@react-native-esbuild/hmr'; import type { BundleOptions } from '@react-native-esbuild/config'; -import type { JscConfig } from '@swc/core'; export interface Config { /** @@ -58,10 +57,6 @@ export interface Config { * Transformer configurations */ transformer?: { - /** - * Swc's `jsc` config. - */ - jsc: Pick; /** * Strip flow syntax. * @@ -125,6 +120,17 @@ export interface Config { * Additional Esbuild plugins. */ plugins?: Plugin[]; + /** + * Experimental configurations + */ + experimental?: { + /** + * Enable HMR(Hot Module Replacement) on development mode. + * + * Defaults to `false`. + */ + hmr?: boolean; + }; /** * Client event receiver (only work on native) */ @@ -167,10 +173,7 @@ export type ReactNativeEsbuildPluginCreator = ( ) => Plugin; export interface BundlerSharedData { - watcher: { - changed: string | null; - stats?: Stats; - }; + bundleMeta?: BundleMeta; } export type BundlerAdditionalData = Record; @@ -180,6 +183,8 @@ export interface PluginContext extends BundleOptions { root: string; config: Config; mode: BundleMode; + enableHmr: boolean; + externalPattern: string; additionalData?: BundlerAdditionalData; } diff --git a/packages/core/package.json b/packages/core/package.json index 7005cc38..93bc38f3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@react-native-esbuild/config": "workspace:*", + "@react-native-esbuild/hmr": "workspace:*", "@react-native-esbuild/internal": "workspace:*", "@react-native-esbuild/transformer": "workspace:*", "@react-native-esbuild/utils": "workspace:*", @@ -51,7 +52,8 @@ "ora": "^5.4.1" }, "peerDependencies": { - "react-native": "*" + "react-native": "*", + "swc-plugin-global-module": "*" }, "devDependencies": { "@babel/core": "^7.23.2", diff --git a/packages/dev-server/lib/middlewares/hmr.ts b/packages/dev-server/lib/middlewares/hmr.ts new file mode 100644 index 00000000..42c26f32 --- /dev/null +++ b/packages/dev-server/lib/middlewares/hmr.ts @@ -0,0 +1,111 @@ +import { + HmrAppServer, + HmrWebServer, + type HmrClientMessage, +} from '@react-native-esbuild/hmr'; +import { getReloadByDevSettingsProxy } from '@react-native-esbuild/internal'; +import type { HmrMiddleware } from '../types'; +import { logger } from '../shared'; + +export const createHmrMiddlewareForApp = ({ + onMessage, +}: { + onMessage?: (event: HmrClientMessage) => void; +}): HmrMiddleware => { + const server = new HmrAppServer(); + + server.setMessageHandler((event) => onMessage?.(event)); + + const updateStart = (): void => { + logger.debug('send update-start message'); + server.send('update-start', { isInitialUpdate: false }); + }; + + const updateDone = (): void => { + logger.debug('send update-done message'); + server.send('update-done', undefined); + }; + + const hotReload = (revisionId: string, code: string): void => { + logger.debug('send update message (hmr)'); + server.send('update', { + added: [ + { + module: [-1, code], + sourceMappingURL: null, + sourceURL: null, + }, + ], + deleted: [], + modified: [], + isInitialUpdate: false, + revisionId, + }); + }; + + const liveReload = (revisionId: string): void => { + logger.debug('send update message (live reload)'); + server.send('update', { + added: [ + { + module: [-1, getReloadByDevSettingsProxy()], + sourceMappingURL: null, + sourceURL: null, + }, + ], + deleted: [], + modified: [], + isInitialUpdate: false, + revisionId, + }); + }; + + return { server, updateStart, updateDone, hotReload, liveReload }; +}; + +export const createHmrMiddlewareForWeb = (): HmrMiddleware => { + const server = new HmrWebServer(); + + // eslint-disable-next-line @typescript-eslint/no-empty-function -- noop + const noop = (): void => {}; + + return { + server, + updateStart: noop, + updateDone: noop, + hotReload: (_revisionId: string, _code: string): void => { + logger.debug('send update message (hmr)'); + // TODO + // server.send('update', { + // added: [ + // { + // module: [-1, code], + // sourceMappingURL: null, + // sourceURL: null, + // }, + // ], + // deleted: [], + // modified: [], + // isInitialUpdate: false, + // revisionId, + // }); + }, + liveReload: (_revisionId: string): void => { + logger.debug('send update message (live reload)'); + // TODO + // server.send('update', { + // added: [ + // { + // module: [-1, 'window.location.reload();'], + // sourceMappingURL: null, + // sourceURL: null, + // }, + // ], + // deleted: [], + // modified: [], + // isInitialUpdate: false, + // revisionId, + // }); + }, + }; +}; diff --git a/packages/dev-server/lib/middlewares/hotReload.ts b/packages/dev-server/lib/middlewares/hotReload.ts deleted file mode 100644 index 3ce6a213..00000000 --- a/packages/dev-server/lib/middlewares/hotReload.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Server, type WebSocket, type MessageEvent, type Data } from 'ws'; -import type { ClientLogEvent } from '@react-native-esbuild/core'; -import { getReloadByDevSettingsProxy } from '@react-native-esbuild/internal'; -import { logger } from '../shared'; -import type { - HotReloadMiddleware, - HmrClientMessage, - HmrUpdateDoneMessage, - HmrUpdateMessage, - HmrUpdateStartMessage, -} from '../types'; - -const getMessage = (data: Data): HmrClientMessage | null => { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Web socket data. - const parsedData = JSON.parse(String(data)); - return 'type' in parsedData ? (parsedData as HmrClientMessage) : null; - } catch (error) { - return null; - } -}; - -export const createHotReloadMiddleware = ({ - onLog, -}: { - onLog?: (event: ClientLogEvent) => void; -}): HotReloadMiddleware => { - const server = new Server({ noServer: true }); - let connectedSocket: WebSocket | null = null; - - const handleClose = (): void => { - connectedSocket = null; - logger.debug('HMR web socket was closed'); - }; - - const handleMessage = (event: MessageEvent): void => { - const message = getMessage(event.data); - if (!message) return; - - /** - * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro/src/HmrServer.js#L200-L239} - */ - switch (message.type) { - case 'log': { - onLog?.({ - type: 'client_log', - level: message.level, - data: message.data, - mode: 'BRIDGE', - }); - break; - } - - // Not supported - case 'register-entrypoints': - case 'log-opt-in': - break; - } - }; - - const handleError = (error?: Error): void => { - if (error) { - logger.error('unable to send HMR update message', error); - } - }; - - /** - * Send reload code to client. - * - * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro-runtime/src/modules/HMRClient.js#L91-L99} - */ - const hotReload = (revisionId: string): void => { - const hmrUpdateMessage: HmrUpdateMessage = { - type: 'update', - body: { - added: [ - { - module: [-1, getReloadByDevSettingsProxy()], - sourceMappingURL: null, - sourceURL: null, - }, - ], - deleted: [], - modified: [], - isInitialUpdate: false, - revisionId, - }, - }; - - logger.debug('sending update message with reload code'); - connectedSocket?.send(JSON.stringify(hmrUpdateMessage), handleError); - }; - - const updateStart = (): void => { - logger.debug('sending update-start'); - const hmrUpdateStartMessage: HmrUpdateStartMessage = { - type: 'update-start', - body: { - isInitialUpdate: false, - }, - }; - connectedSocket?.send(JSON.stringify(hmrUpdateStartMessage), handleError); - }; - - const updateDone = (): void => { - logger.debug('sending update-done'); - const hmrUpdateDoneMessage: HmrUpdateDoneMessage = { type: 'update-done' }; - connectedSocket?.send(JSON.stringify(hmrUpdateDoneMessage), handleError); - }; - - server.on('connection', (socket) => { - connectedSocket = socket; - connectedSocket.onerror = handleClose; - connectedSocket.onclose = handleClose; - connectedSocket.onmessage = handleMessage; - logger.debug('HMR web socket was connected'); - }); - - server.on('error', (error) => { - logger.error('HMR web socket server error', error); - }); - - return { server, hotReload, updateStart, updateDone }; -}; diff --git a/packages/dev-server/lib/middlewares/index.ts b/packages/dev-server/lib/middlewares/index.ts index 6e88227f..caf48ffb 100644 --- a/packages/dev-server/lib/middlewares/index.ts +++ b/packages/dev-server/lib/middlewares/index.ts @@ -1,4 +1,4 @@ -export * from './hotReload'; +export * from './hmr'; export * from './indexPage'; export * from './serveAsset'; export * from './serveBundle'; diff --git a/packages/dev-server/lib/server/ReactNativeAppServer.ts b/packages/dev-server/lib/server/ReactNativeAppServer.ts index 1a18b19c..fea00129 100644 --- a/packages/dev-server/lib/server/ReactNativeAppServer.ts +++ b/packages/dev-server/lib/server/ReactNativeAppServer.ts @@ -7,7 +7,7 @@ import { InspectorProxy } from 'metro-inspector-proxy'; import { createDevServerMiddleware } from '@react-native-community/cli-server-api'; import { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; import { - createHotReloadMiddleware, + createHmrMiddlewareForApp, createServeAssetMiddleware, createServeBundleMiddleware, createSymbolicateMiddleware, @@ -105,10 +105,26 @@ export class ReactNativeAppServer extends DevServer { throw new Error('server is not initialized'); } - const { server: hotReloadWss, ...hr } = createHotReloadMiddleware({ - onLog: (event) => { - this.eventsSocketEndpoint.reportEvent(event); - this.bundler?.emit('report', event); + const { server: hmrServer, ...hmr } = createHmrMiddlewareForApp({ + onMessage: (message) => { + switch (message.type) { + case 'log': { + const clientLogEvent = { + type: 'client_log', + level: message.level, + data: message.data, + mode: 'BRIDGE', + } as const; + this.bundler?.emit('report', clientLogEvent); + this.eventsSocketEndpoint.reportEvent(clientLogEvent); + break; + } + + // not supported + case 'register-entrypoints': + case 'log-opt-in': + break; + } }, }); @@ -123,20 +139,22 @@ export class ReactNativeAppServer extends DevServer { ); const webSocketServer: Record = { - '/hot': hotReloadWss, + '/hot': hmrServer.getWebSocketServer(), '/debugger-proxy': this.debuggerProxyEndpoint.server, '/message': this.messageSocketEndpoint.server, '/events': this.eventsSocketEndpoint.server, ...inspectorProxyWss, }; - this.bundler.on('build-start', hr.updateStart); - this.bundler.on('build-end', ({ revisionId, additionalData }) => { + this.bundler.on('build-start', hmr.updateStart); + this.bundler.on('build-end', ({ revisionId, update, additionalData }) => { // `additionalData` can be `{ disableRefresh: true }` by `serve-asset-middleware`. if (!additionalData?.disableRefresh) { - hr.hotReload(revisionId); + update === null || update.fullyReload + ? hmr.liveReload(revisionId) + : hmr.hotReload(revisionId, update.code); } - hr.updateDone(); + hmr.updateDone(); }); this.server.on('upgrade', (request, socket, head) => { diff --git a/packages/dev-server/lib/server/ReactNativeWebServer.ts b/packages/dev-server/lib/server/ReactNativeWebServer.ts index c9fd5f1e..de5291ef 100644 --- a/packages/dev-server/lib/server/ReactNativeWebServer.ts +++ b/packages/dev-server/lib/server/ReactNativeWebServer.ts @@ -3,6 +3,7 @@ import http, { type ServerResponse, type IncomingMessage, } from 'node:http'; +import { parse } from 'node:url'; import type { ServeResult } from 'esbuild'; import invariant from 'invariant'; import { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; @@ -10,7 +11,10 @@ import { combineWithDefaultBundleOptions, type BundleOptions, } from '@react-native-esbuild/config'; -import { createSymbolicateMiddleware } from '../middlewares'; +import { + createHmrMiddlewareForWeb, + createSymbolicateMiddleware, +} from '../middlewares'; import { logger } from '../shared'; import type { DevServerOptions } from '../types'; import { DevServer } from './DevServer'; @@ -96,6 +100,16 @@ export class ReactNativeWebServer extends DevServer { this.devServerOptions.root, ).initialize({ watcherEnabled: true })); + const { server: hmrServer, ...hmr } = createHmrMiddlewareForWeb(); + + 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 }, { webBundleOptions: this.bundleOptions }, @@ -114,6 +128,20 @@ export class ReactNativeWebServer extends DevServer { }); }); + this.server.on('upgrade', (request, socket, head) => { + if (!request.url) return; + const { pathname } = parse(request.url); + + if (pathname === '/hot') { + const wss = hmrServer.getWebSocketServer(); + wss.handleUpgrade(request, socket, head, (client) => { + wss.emit('connection', client, request); + }); + } else { + socket.destroy(); + } + }); + await onPostSetup?.(bundler); return this; diff --git a/packages/dev-server/lib/types.ts b/packages/dev-server/lib/types.ts index 36ac5c11..0c720f9d 100644 --- a/packages/dev-server/lib/types.ts +++ b/packages/dev-server/lib/types.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { Server as WebSocketServer } from 'ws'; import type { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; +import type { HmrServer } from '@react-native-esbuild/hmr'; export enum BundleRequestType { Unknown, @@ -30,11 +30,12 @@ export type DevServerMiddleware = ( next: (error?: unknown) => void, ) => void; -export interface HotReloadMiddleware { - server: WebSocketServer; - hotReload: (revisionId: string) => void; +export interface HmrMiddleware { + server: HmrServer; updateStart: () => void; updateDone: () => void; + hotReload: (revisionId: string, code: string) => void; + liveReload: (revisionId: string) => void; } /** diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index ed84c8a9..24955fdf 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -49,6 +49,7 @@ "@react-native-community/cli-server-api": "^11.3.6", "@react-native-esbuild/config": "workspace:*", "@react-native-esbuild/core": "workspace:*", + "@react-native-esbuild/hmr": "workspace:*", "@react-native-esbuild/internal": "workspace:*", "@react-native-esbuild/symbolicate": "workspace:*", "@react-native-esbuild/utils": "workspace:*", diff --git a/packages/hmr/README.md b/packages/hmr/README.md new file mode 100644 index 00000000..3c587cde --- /dev/null +++ b/packages/hmr/README.md @@ -0,0 +1,52 @@ +# `@react-native-esbuild/hmr` + +> `react-refresh` based HMR implementation for @react-native-esbuild + +## Usage + +1. Add import statement to top of entry file(`import 'hmr:runtime';`) +2. Wrap React component module with `wrapWithHmrBoundary` + +```js +import { + getHmrRuntimeInitializeScript, + wrapWithHmrBoundary, + HMR_RUNTIME_IMPORT_NAME +} from '@react-native-esbuild/hmr'; + +// In esbuild plugin +build.onResolve({ filter: new RegExp(HMR_RUNTIME_IMPORT_NAME) }, (args) => { + return { + path: args.path, + namespace: 'hmr-runtime', + }; +}); + +build.onLoad({ filter: /(?:.*)/, namespace: 'hmr-runtime' }, (args) => { + return { + js: await getHmrRuntimeInitializeScript(), + loader: 'js', + }; +}); + +build.onLoad({ filter: /* some filter */ }, (args) => { + if (isEntryFile) { + code = await fs.readFile(args.path, 'utf-8'); + code = `import ${HMR_RUNTIME_IMPORT_NAME};\n\n` + code; + // ... + + return { + contents: code, + loader: 'js', + }; + } else { + // ... + + return { + // code, component name, module id + contents: wrapWithHmrBoundary(code, '', args.path), + loader: 'js', + }; + } +}); +``` diff --git a/packages/hmr/build/index.js b/packages/hmr/build/index.js new file mode 100644 index 00000000..9ea82862 --- /dev/null +++ b/packages/hmr/build/index.js @@ -0,0 +1,25 @@ +const path = require('node:path'); +const esbuild = require('esbuild'); +const { getEsbuildBaseOptions, getPackageRoot } = require('../../../shared'); +const { name, version } = require('../package.json'); + +const buildOptions = getEsbuildBaseOptions(__dirname); +const root = getPackageRoot(__dirname); + +(async () => { + // package + await esbuild.build(buildOptions); + + // runtime + await esbuild.build({ + entryPoints: [path.join(root, './lib/runtime/setup.ts')], + outfile: 'dist/runtime.js', + bundle: true, + banner: { + js: `// ${name}@${version} runtime`, + }, + }); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/hmr/lib/HmrTransformer.ts b/packages/hmr/lib/HmrTransformer.ts new file mode 100644 index 00000000..a92bfe1b --- /dev/null +++ b/packages/hmr/lib/HmrTransformer.ts @@ -0,0 +1,294 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { Metafile } from 'esbuild'; +import { getReloadByDevSettingsProxy } from '@react-native-esbuild/internal'; +import { + AsyncTransformPipeline, + swcPresets, +} from '@react-native-esbuild/transformer'; +import type { TransformerContext } from '@react-native-esbuild/transformer'; +import { colors } from '@react-native-esbuild/utils'; +import { logger } from './shared'; +import { + HMR_REGISTER_FUNCTION, + HMR_UPDATE_FUNCTION, + REACT_REFRESH_REGISTER_FUNCTION, + REACT_REFRESH_SIGNATURE_FUNCTION, +} from './constants'; +import type { + BundleMeta, + BundleUpdate, + ModuleInfo, + PipelineBuilderOptions, +} from './types'; + +const DUMMY_ESBUILD_ARGS = { + namespace: '', + suffix: '', + pluginData: undefined, +} as const; + +export class HmrTransformer { + private static boundaryIndex = 0; + private stripRootRegex: RegExp; + private externalPatternRegex: RegExp; + private pipeline: AsyncTransformPipeline; + + public static isBoundary(path: string): boolean { + // `runtime.js`: To avoid wrong HMR behavior in monorepo. + return !path.includes('/node_modules/') && !path.endsWith('runtime.js'); + } + + public static asBoundary(id: string, code: string): string { + const ident = `__hmr${HmrTransformer.boundaryIndex++}`; + return `var ${ident} = ${HMR_REGISTER_FUNCTION}(${JSON.stringify(id)}); + ${code} + ${ident}.dispose(function () { }); + ${ident}.accept(function (payload) { + global.__hmr.reactRefresh.performReactRefresh(); + });`; + } + + public static registerAsExternalModule( + id: string, + code: string, + identifier: string, + ): string { + return `${code}\nglobal.__modules.external(${JSON.stringify( + id, + )}, ${identifier});`; + } + + constructor( + private context: Omit & { + externalPattern: string; + }, + builderOptions: PipelineBuilderOptions, + ) { + const { + fullyTransformPackageNames = [], + stripFlowPackageNames = [], + additionalBabelRules = [], + additionalSwcRules = [], + } = builderOptions; + this.stripRootRegex = new RegExp(`^${context.root}/?`); + this.externalPatternRegex = new RegExp(context.externalPattern); + this.pipeline = new AsyncTransformPipeline.builder(context) + .setSwcPreset( + swcPresets.getReactNativeRuntimePreset({ + experimental: { + hmr: { + runtime: true, + refreshReg: REACT_REFRESH_REGISTER_FUNCTION, + refreshSig: REACT_REFRESH_SIGNATURE_FUNCTION, + }, + }, + }), + ) + .setFullyTransformPackages(fullyTransformPackageNames) + .setStripFlowPackages(stripFlowPackageNames) + .setAdditionalBabelTransformRules(additionalBabelRules) + .setAdditionalSwcTransformRules(additionalSwcRules) + .build(); + } + + /** + * Enhance `esbuild.Metafile` to `BundleMeta`. + * + * Add `parents` property into each module info. + * This property will be used as indexed value for explore parent modules. + */ + public static 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.debug(`'${path}' is not exist in esbuild 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; + } + + public async getDelta( + modulePath: string, + metafile: Metafile, + ): Promise { + try { + const startedAt = performance.now(); + const { code, target, dependencies } = await this.transformRuntime( + modulePath, + metafile, + ); + const endedAt = performance.now(); + this.stripRoot(modulePath); + logger.info( + [ + target, + colors.gray(`+ ${dependencies} module(s)`), + 'transformed in', + colors.cyan(`${Math.floor(endedAt - startedAt)}ms`), + ].join(' '), + ); + return { + id: '', + code: this.asFallbackBoundary(code), + path: modulePath, + fullyReload: false, + }; + } catch (error) { + logger.error('unable to transform runtime modules', error as Error); + return { + id: '', + code: '', + path: modulePath, + fullyReload: true, + }; + } + } + + private stripRoot(path: string): string { + return path.replace(this.stripRootRegex, ''); + } + + private async transformRuntime( + modulePath: string, + bundleMeta: BundleMeta, + ): Promise<{ code: string; target: string; dependencies: number }> { + const strippedModulePath = this.stripRoot(modulePath); + const reverseDependencies = this.getReverseDependencies( + strippedModulePath, + bundleMeta, + ); + const transformFlags: Record = {}; + const transformedCodes = await Promise.all( + [modulePath, ...reverseDependencies].map(async (modulePath) => { + // To avoid re-transformation. + if (transformFlags[modulePath]) { + return ''; + } + transformFlags[modulePath] = true; + + const rawCode = await fs.readFile(modulePath, 'utf-8'); + const importPaths = this.getActualImportPaths( + this.stripRoot(modulePath), + bundleMeta, + ); + + const { code } = await this.pipeline.transform( + rawCode, + { ...DUMMY_ESBUILD_ARGS, path: modulePath }, + { externalPattern: this.context.externalPattern, importPaths }, + ); + + return this.asCallHmrUpdate(modulePath, code); + }), + ); + + return { + code: transformedCodes.join('\n'), + target: strippedModulePath, + dependencies: Object.keys(transformFlags).length, + }; + } + + private getTargetModuleInfo( + targetModule: string, + bundleMeta: BundleMeta, + ): ModuleInfo { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- can be `undefined` + if (!bundleMeta.inputs[targetModule]) { + throw new Error(`[HMR] ${targetModule} not found in bundle meta`); + } + return bundleMeta.inputs[targetModule]; + } + + private getActualImportPaths( + targetModule: string, + bundleMeta: BundleMeta, + ): Record { + logger.debug(`${colors.cyan(targetModule)} imports`); + const moduleInfo = this.getTargetModuleInfo(targetModule, bundleMeta); + const importPaths = moduleInfo.imports.reduce( + (prev, curr) => { + // To avoid wrong assets path. + // eg. `react-native-esbuild-assets:/path/to/assets` + const splitted = path.resolve(this.context.root, curr.path).split(':'); + const actualPath = splitted[splitted.length - 1]; + if (curr.original && !prev[curr.original]) { + logger.debug( + `${colors.gray(`├─ ${this.stripRoot(curr.original)} ▸`)} ${ + this.externalPatternRegex.test(curr.original) + ? colors.gray('') + : this.stripRoot(actualPath) + }`, + ); + } + return { + ...prev, + ...(curr.original ? { [curr.original]: actualPath } : null), + }; + }, + {} as Record, + ); + + logger.debug( + colors.gray(`╰─ ${Object.keys(importPaths).length} import(s)`), + ); + + return importPaths; + } + + private getReverseDependencies( + targetModule: string, + bundleMeta: BundleMeta, + dependencies: string[] = [], + depth = 0, + ): string[] { + if (depth === 0) logger.debug(colors.gray(targetModule)); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- can be `undefined` + if (bundleMeta.inputs[targetModule]?.parents) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- after if statement. + bundleMeta.inputs[targetModule].parents!.forEach((parentModule) => { + const spaces = new Array(depth * 3).fill(' ').join(''); + logger.debug(colors.gray(`${spaces}╰─ ${parentModule}`)); + + dependencies = this.getReverseDependencies( + parentModule, + bundleMeta, + [...dependencies, path.join(this.context.root, parentModule)], + depth + 1, + ); + }); + } + + return dependencies; + } + + private asCallHmrUpdate(id: string, code: string): string { + return `${HMR_UPDATE_FUNCTION}(${JSON.stringify(id)}, function () { + ${code} + });`; + } + + private asFallbackBoundary(code: string): string { + return `try { + ${code} + } catch (error) { + console.error('[HMR] unable to accept', error); + ${getReloadByDevSettingsProxy()} + }`; + } +} diff --git a/packages/hmr/lib/constants.ts b/packages/hmr/lib/constants.ts new file mode 100644 index 00000000..93d203a5 --- /dev/null +++ b/packages/hmr/lib/constants.ts @@ -0,0 +1,11 @@ +/** + * WARNING: Property and function identifiers must match the names defined in `types.ts`. + */ +export const HMR_REGISTER_FUNCTION = 'global.__hmr.register'; +export const HMR_UPDATE_FUNCTION = 'global.__hmr.update'; +export const REACT_REFRESH_REGISTER_FUNCTION = + 'global.__hmr.reactRefresh.register'; +export const REACT_REFRESH_SIGNATURE_FUNCTION = + 'global.__hmr.reactRefresh.getSignature'; +export const PERFORM_REACT_REFRESH_SCRIPT = + 'global.__hmr.reactRefresh.performReactRefresh()'; diff --git a/packages/hmr/lib/index.ts b/packages/hmr/lib/index.ts new file mode 100644 index 00000000..e1f8201e --- /dev/null +++ b/packages/hmr/lib/index.ts @@ -0,0 +1,5 @@ +export { HmrTransformer } from './HmrTransformer'; +export * from './server'; +export * from './constants'; +export type * from './types'; +export type { HmrServer } from './server/HmrServer'; diff --git a/packages/hmr/lib/runtime/setup.ts b/packages/hmr/lib/runtime/setup.ts new file mode 100644 index 00000000..3a8f3cdb --- /dev/null +++ b/packages/hmr/lib/runtime/setup.ts @@ -0,0 +1,125 @@ +import * as RefreshRuntime from 'react-refresh/runtime'; + +type HotModuleReplacementAcceptCallback = (payload: { + id: HotModuleReplacementId; +}) => void; + +type HotModuleReplacementDisposeCallback = () => void; + +if (__DEV__ && typeof global.__hmr === 'undefined') { + const HMR_DEBOUNCE_DELAY = 50; + let performReactRefreshTimeout: NodeJS.Timeout | null = null; + + class HotModuleReplacementContext { + public static registry: Record< + HotModuleReplacementId, + HotModuleReplacementContext | undefined + > = {}; + public locked = false; + public acceptCallbacks: HotModuleReplacementAcceptCallback[] = []; + public disposeCallbacks: HotModuleReplacementDisposeCallback[] = []; + + constructor(public id: HotModuleReplacementId) {} + + accept(acceptCallback: HotModuleReplacementAcceptCallback): void { + if (this.locked) return; + this.acceptCallbacks.push(acceptCallback); + } + + dispose(disposeCallback: HotModuleReplacementDisposeCallback): void { + if (this.locked) return; + this.disposeCallbacks.push(disposeCallback); + } + + lock(): void { + this.locked = true; + } + } + + const isReactRefreshBoundary = (type: unknown): boolean => { + return Boolean( + RefreshRuntime.isLikelyComponentType(type) && + // @ts-expect-error - expect a ReactElement + !type?.prototype?.isReactComponent, + ); + }; + + const HotModuleReplacementRuntimeModule: HotModuleReplacementRuntimeModule = { + register: (id: HotModuleReplacementId) => { + const context = HotModuleReplacementContext.registry[id]; + if (context) { + context.lock(); + return context; + } + return (HotModuleReplacementContext.registry[id] = + new HotModuleReplacementContext(id)); + }, + update: (id: HotModuleReplacementId, evalUpdates: () => void) => { + const context = HotModuleReplacementContext.registry[id]; + context?.disposeCallbacks.forEach((callback) => { + callback(); + }); + evalUpdates(); + context?.acceptCallbacks.forEach((callback) => { + callback({ id }); + }); + }, + reactRefresh: { + register: (type: unknown, id: string) => { + if (!isReactRefreshBoundary(type)) return; + RefreshRuntime.register(type, id); + }, + getSignature: () => { + const signature: any = + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -- Wrong type definition. + RefreshRuntime.createSignatureFunctionForTransform(); + return ( + type: unknown, + id: string, + forceReset?: boolean, + getCustomHooks?: () => unknown[], + ) => { + if (!isReactRefreshBoundary(type)) return; + + return signature(type, id, forceReset, getCustomHooks) as unknown; + }; + }, + performReactRefresh: () => { + if (performReactRefreshTimeout !== null) { + return; + } + + performReactRefreshTimeout = setTimeout(() => { + performReactRefreshTimeout = null; + if (RefreshRuntime.hasUnrecoverableErrors()) { + console.error('[HMR::react-refresh] has unrecoverable errors'); + return; + } + RefreshRuntime.performReactRefresh(); + }, HMR_DEBOUNCE_DELAY); + }, + }, + }; + + RefreshRuntime.injectIntoGlobalHook(global); + + Object.defineProperty(global, '__hmr', { + enumerable: false, + value: HotModuleReplacementRuntimeModule, + }); + + // for web + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- allow + const isWeb = global?.navigator?.appName === 'Netscape'; + if (isWeb) { + const socketURL = new URL('hot', `ws://${window.location.host}`); + const socket = new window.WebSocket(socketURL.href); + socket.addEventListener('message', (event) => { + const payload = window.JSON.parse(event.data); + if (payload.type === 'update') { + const code = payload.body?.added[0]?.module?.[1]; + code && window.eval(code); + } + }); + } +} diff --git a/packages/hmr/lib/server/HmrAppServer.ts b/packages/hmr/lib/server/HmrAppServer.ts new file mode 100644 index 00000000..0ae88b50 --- /dev/null +++ b/packages/hmr/lib/server/HmrAppServer.ts @@ -0,0 +1,54 @@ +import type { Server, MessageEvent, Data } from 'ws'; +import type { HmrClientMessage, HmrMessage, HmrMessageType } from '../types'; +import { HmrServer } from './HmrServer'; + +export class HmrAppServer extends HmrServer { + private messageHandler?: (message: HmrClientMessage) => void; + + constructor() { + super(); + this.setup((socket) => { + socket.onmessage = this.handleMessage.bind(this); + }); + } + + private parseClientMessage(data: Data): HmrClientMessage | null { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- from ws data + const parsedData = JSON.parse(String(data)); + return 'type' in parsedData ? (parsedData as HmrClientMessage) : null; + } catch (error) { + return null; + } + } + + private handleMessage(event: MessageEvent): void { + const message = this.parseClientMessage(event.data); + if (!message) return; + + /** + * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro/src/HmrServer.js#L200-L239} + */ + this.messageHandler?.(message); + } + + public getWebSocketServer(): Server { + return this.server; + } + + public setMessageHandler(handler: (message: HmrClientMessage) => void): void { + this.messageHandler = handler; + } + + public send( + type: MessageType, + body: HmrMessage[MessageType], + ): void { + this.connectedSocket?.send( + JSON.stringify({ + type, + body, + }), + ); + } +} diff --git a/packages/hmr/lib/server/HmrServer.ts b/packages/hmr/lib/server/HmrServer.ts new file mode 100644 index 00000000..904b3160 --- /dev/null +++ b/packages/hmr/lib/server/HmrServer.ts @@ -0,0 +1,40 @@ +import { Server, type WebSocket } from 'ws'; +import { logger } from '../shared'; +import type { HmrMessage, HmrMessageType } from '../types'; + +export abstract class HmrServer { + protected server: Server; + protected connectedSocket?: WebSocket; + + constructor() { + this.server = new Server({ noServer: true }); + } + + private handleClose(): void { + this.connectedSocket = undefined; + logger.debug('HMR web socket was closed'); + } + + public setup(onConnect?: (socket: WebSocket) => void): void { + this.server.on('connection', (socket) => { + this.connectedSocket = socket; + this.connectedSocket.onclose = this.handleClose.bind(this); + this.connectedSocket.onerror = this.handleClose.bind(this); + logger.debug('HMR web socket was connected'); + onConnect?.(socket); + }); + + this.server.on('error', (error) => { + logger.error('HMR web socket server error', error); + }); + } + + public getWebSocketServer(): Server { + return this.server; + } + + public abstract send( + type: MessageType, + body: HmrMessage[MessageType], + ): void; +} diff --git a/packages/hmr/lib/server/HmrWebServer.ts b/packages/hmr/lib/server/HmrWebServer.ts new file mode 100644 index 00000000..6c95b075 --- /dev/null +++ b/packages/hmr/lib/server/HmrWebServer.ts @@ -0,0 +1,21 @@ +import type { HmrMessage, HmrMessageType } from '../types'; +import { HmrServer } from './HmrServer'; + +export class HmrWebServer extends HmrServer { + constructor() { + super(); + this.setup(); + } + + public send( + type: MessageType, + body: HmrMessage[MessageType], + ): void { + this.connectedSocket?.send( + JSON.stringify({ + type, + body, + }), + ); + } +} diff --git a/packages/hmr/lib/server/index.ts b/packages/hmr/lib/server/index.ts new file mode 100644 index 00000000..7360b435 --- /dev/null +++ b/packages/hmr/lib/server/index.ts @@ -0,0 +1,2 @@ +export * from './HmrAppServer'; +export * from './HmrWebServer'; diff --git a/packages/hmr/lib/shared.ts b/packages/hmr/lib/shared.ts new file mode 100644 index 00000000..8523c002 --- /dev/null +++ b/packages/hmr/lib/shared.ts @@ -0,0 +1,3 @@ +import { Logger } from '@react-native-esbuild/utils'; + +export const logger = new Logger('hmr'); diff --git a/packages/hmr/lib/types.ts b/packages/hmr/lib/types.ts new file mode 100644 index 00000000..5ee0cb8c --- /dev/null +++ b/packages/hmr/lib/types.ts @@ -0,0 +1,128 @@ +import type { + BabelTransformRule, + SwcTransformRule, +} from '@react-native-esbuild/transformer'; +import type { Metafile, ImportKind } from 'esbuild'; + +/* eslint-disable no-var -- allow */ +declare global { + type HotModuleReplacementId = string; + + interface HotModuleReplacementRuntimeModule { + register: (id: HotModuleReplacementId) => void; + update: (id: HotModuleReplacementId, evalUpdates: () => void) => void; + // react-refresh/runtime + reactRefresh: { + register: (type: unknown, id: string) => void; + getSignature: () => ( + type: unknown, + id: string, + forceReset?: boolean, + getCustomHooks?: () => unknown[], + ) => unknown; + performReactRefresh: () => void; + }; + } + + // react-native + var __DEV__: boolean; + + // react-refresh/runtime + var __hmr: HotModuleReplacementRuntimeModule | undefined; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- allow + var window: any; +} + +export interface PipelineBuilderOptions { + fullyTransformPackageNames?: string[]; + stripFlowPackageNames?: string[]; + additionalBabelRules?: BabelTransformRule[]; + additionalSwcRules?: SwcTransformRule[]; +} + +/** + * HMR web socket messages + * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro-runtime/src/modules/types.flow.js#L68} + */ +export type HmrClientMessage = + | RegisterEntryPointsMessage + | LogMessage + | LogOptInMessage; + +export interface RegisterEntryPointsMessage { + type: 'register-entrypoints'; + entryPoints: string[]; +} + +export interface LogMessage { + type: 'log'; + level: + | 'trace' + | 'info' + | 'warn' + | 'log' + | 'group' + | 'groupCollapsed' + | 'groupEnd' + | 'debug'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- follow metro types + data: any[]; + mode: 'BRIDGE' | 'NOBRIDGE'; +} + +export interface LogOptInMessage { + type: 'log-opt-in'; +} + +/** + * HMR update message + * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro-runtime/src/modules/types.flow.js#L44-L56} + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- allow type +export type HmrMessage = { + update: HmrUpdate; + 'update-start': { + isInitialUpdate: boolean; + }; + 'update-done': undefined; +}; + +export type HmrMessageType = keyof HmrMessage; + +export interface HmrUpdate { + readonly added: HmrModule[]; + readonly deleted: number[]; + readonly modified: HmrModule[]; + isInitialUpdate: boolean; + revisionId: string; +} +export interface HmrModule { + module: [number, string]; + sourceMappingURL: string | null; + sourceURL: string | null; +} + +export type BundleMeta = Metafile & { + inputs: Metafile['inputs'] & Record; +}; + +// Extended type from `esbuild.Metafile['inputs'][file]` +export interface ModuleInfo { + 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 new file mode 100644 index 00000000..33e0b98d --- /dev/null +++ b/packages/hmr/package.json @@ -0,0 +1,57 @@ +{ + "name": "@react-native-esbuild/hmr", + "version": "0.1.0-beta.8", + "description": "HMR implementation for @react-native-esbuild", + "keywords": [ + "react-native", + "esbuild", + "hmr", + "react-refresh" + ], + "author": "leegeunhyeok ", + "homepage": "https://github.com/leegeunhyeok/react-native-esbuild#readme", + "license": "MIT", + "type": "commonjs", + "module": "lib/index.ts", + "main": "dist/index.js", + "types": "dist/lib/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./runtime": "./dist/runtime.js" + }, + "directories": { + "lib": "lib", + "test": "__tests__" + }, + "files": [ + "lib", + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/leegeunhyeok/react-native-esbuild.git" + }, + "scripts": { + "prepack": "yarn build", + "cleanup": "rimraf ./dist", + "build": "node build/index.js && tsc" + }, + "bugs": { + "url": "https://github.com/leegeunhyeok/react-native-esbuild/issues" + }, + "devDependencies": { + "@swc/helpers": "^0.5.2", + "@types/react-refresh": "^0.14.3", + "esbuild": "^0.19.3" + }, + "dependencies": { + "@react-native-esbuild/internal": "workspace:^", + "@react-native-esbuild/transformer": "workspace:^", + "@react-native-esbuild/utils": "workspace:*", + "react-refresh": "^0.14.0", + "ws": "^8.14.2" + } +} diff --git a/packages/hmr/tsconfig.json b/packages/hmr/tsconfig.json new file mode 100644 index 00000000..bd42e358 --- /dev/null +++ b/packages/hmr/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist", + "declaration": true, + "emitDeclarationOnly": true + }, + "include": ["./lib"], + "exclude": ["./dist"] +} diff --git a/packages/internal/lib/__tests__/__snapshots__/presets.test.ts.snap b/packages/internal/lib/__tests__/__snapshots__/presets.test.ts.snap index ddf3b952..618b9261 100644 --- a/packages/internal/lib/__tests__/__snapshots__/presets.test.ts.snap +++ b/packages/internal/lib/__tests__/__snapshots__/presets.test.ts.snap @@ -1,7 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`getAssetRegistrationScript should match snapshot 1`] = ` -" - module.exports = require('react-native/Libraries/Image/AssetRegistry').registerAsset({"__packager_asset":true,"name":"image","type":"png","scales":[1,2,3],"hash":"hash","httpServerLocation":"/image.png","width":0,"height":0}); - " -`; +exports[`getAssetRegistrationScript should match snapshot 1`] = `"module.exports =require('react-native/Libraries/Image/AssetRegistry').registerAsset({"__packager_asset":true,"name":"image","type":"png","scales":[1,2,3],"hash":"hash","httpServerLocation":"/image.png","width":0,"height":0});"`; diff --git a/packages/internal/lib/presets.ts b/packages/internal/lib/presets.ts index 5cdd1b05..aa6d11e9 100644 --- a/packages/internal/lib/presets.ts +++ b/packages/internal/lib/presets.ts @@ -22,19 +22,13 @@ export const getInjectVariables = (dev: boolean): string[] => [ `global = typeof globalThis !== 'undefined' ? globalThis : typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : this`, ]; -const getReactNativePolyfills = (root: string): Promise => { +const getReactNativePolyfills = (root: string): string[] => { // eslint-disable-next-line @typescript-eslint/no-var-requires -- Allow dynamic require. const getPolyfills = require( resolveFromRoot(REACT_NATIVE_GET_POLYFILLS_PATH, root), ) as () => string[]; - return Promise.all( - getPolyfills().map((scriptPath) => - fs - .readFile(scriptPath, { encoding: 'utf-8' }) - .then((code) => wrapWithIIFE(code, scriptPath)), - ), - ); + return getPolyfills(); }; export const getReactNativeInitializeCore = (root: string): string => { @@ -56,16 +50,24 @@ export const getPreludeScript = async ( { dev = true }: BundleOptions, root: string, ): Promise => { - const polyfills = await getReactNativePolyfills(root); + const scripts = await Promise.all( + getReactNativePolyfills(root).map((scriptPath) => + fs + .readFile(scriptPath, { encoding: 'utf-8' }) + .then((code) => wrapWithIIFE(code, scriptPath)), + ), + ); + const initialScripts = [ `var ${getInjectVariables(dev).join(',')};`, `process.env=process.env||{};`, `process.env.NODE_ENV=${JSON.stringify(getNodeEnv(dev))};`, - ...polyfills, + ...scripts, ].join('\n'); return initialScripts; }; + /** * Get asset registration script. * @@ -83,20 +85,18 @@ export const getAssetRegistrationScript = ({ Asset, 'name' | 'type' | 'scales' | 'hash' | 'httpServerLocation' | 'dimensions' >): string => { - return ` - module.exports = require('react-native/Libraries/Image/AssetRegistry').registerAsset(${JSON.stringify( - { - __packager_asset: true, - name, - type, - scales, - hash, - httpServerLocation, - width: dimensions.width, - height: dimensions.height, - }, - )}); - `; + return `module.exports =require('react-native/Libraries/Image/AssetRegistry').registerAsset(${JSON.stringify( + { + __packager_asset: true, + name, + type, + scales, + hash, + httpServerLocation, + width: dimensions.width, + height: dimensions.height, + }, + )});`; }; /** diff --git a/packages/plugins/lib/assetRegisterPlugin/assetRegisterPlugin.ts b/packages/plugins/lib/assetRegisterPlugin/assetRegisterPlugin.ts index b115bfda..ccb86f10 100644 --- a/packages/plugins/lib/assetRegisterPlugin/assetRegisterPlugin.ts +++ b/packages/plugins/lib/assetRegisterPlugin/assetRegisterPlugin.ts @@ -6,6 +6,7 @@ import { type Asset, } from '@react-native-esbuild/internal'; import { ASSET_EXTENSIONS } from '@react-native-esbuild/internal'; +import { HmrTransformer } from '@react-native-esbuild/hmr'; import type { AssetRegisterPluginConfig, SuffixPathResult } from '../types'; import { copyAssetsToDestination, @@ -96,12 +97,19 @@ export const createAssetRegisterPlugin: ReactNativeEsbuildPluginCreator< build.onLoad({ filter: /./, namespace: ASSET_NAMESPACE }, async (args) => { const asset = await resolveScaledAssets(context, args); + const assetRegistrationScript = getAssetRegistrationScript(asset); assets.push(asset); return { resolveDir: path.dirname(args.path), - contents: getAssetRegistrationScript(asset), + contents: context.enableHmr + ? HmrTransformer.registerAsExternalModule( + args.path, + assetRegistrationScript, + 'module.exports', + ) + : assetRegistrationScript, loader: 'js', }; }); diff --git a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/index.ts b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/index.ts index c9314409..d4fdc5e5 100644 --- a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/index.ts +++ b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/index.ts @@ -1 +1,2 @@ export * from './caches'; +export * from './json'; diff --git a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/json.ts b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/json.ts new file mode 100644 index 00000000..755febe4 --- /dev/null +++ b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/json.ts @@ -0,0 +1,31 @@ +import fs from 'node:fs/promises'; +import type { PluginBuild } from 'esbuild'; +import { HmrTransformer } from '@react-native-esbuild/hmr'; + +/** + * When development mode & HMR enabled, the '.json' contents + * must be registered in the global module registry for HMR. + */ +export const jsonAsJsModule = (build: PluginBuild): void => { + build.onLoad({ filter: /\.json$/ }, async (args) => { + const rawJson = await fs.readFile(args.path, { encoding: 'utf-8' }); + const parsedJson = JSON.parse(rawJson) as Record; + const identifier = 'json'; + + return { + contents: HmrTransformer.registerAsExternalModule( + args.path, + `const ${identifier} = ${rawJson}; + ${Object.keys(parsedJson) + .map((member) => { + const memberName = JSON.stringify(member); + return `exports[${memberName}] = ${identifier}[${memberName}]`; + }) + .join('\n')} + module.exports = ${identifier};`, + identifier, + ), + loader: 'js', + }; + }); +}; diff --git a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts index 6b9d3026..e3a9990d 100644 --- a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts +++ b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts @@ -1,6 +1,5 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import type { OnLoadResult } from 'esbuild'; import { ReactNativeEsbuildBundler as Bundler, type ReactNativeEsbuildPluginCreator, @@ -11,6 +10,11 @@ import { swcPresets, type AsyncTransformStep, } from '@react-native-esbuild/transformer'; +import { + HmrTransformer, + REACT_REFRESH_REGISTER_FUNCTION, + REACT_REFRESH_SIGNATURE_FUNCTION, +} from '@react-native-esbuild/hmr'; import { logger } from '../shared'; import type { ReactNativeRuntimeTransformPluginConfig } from '../types'; import { @@ -18,6 +22,7 @@ import { getTransformedCodeFromFileSystemCache, writeTransformedCodeToInMemoryCache, writeTransformedCodeToFileSystemCache, + jsonAsJsModule, } from './helpers'; const NAME = 'react-native-runtime-transform-plugin'; @@ -28,7 +33,6 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr name: NAME, setup: (build): void => { const cacheController = Bundler.caches.get(context.id); - const bundlerSharedData = Bundler.shared.get(context.id); const cacheEnabled = context.config.cache ?? true; const { stripFlowPackageNames = [], @@ -38,33 +42,38 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr const additionalBabelRules = additionalTransformRules?.babel ?? []; const additionalSwcRules = additionalTransformRules?.swc ?? []; const injectScriptPaths = [ - getReactNativeInitializeCore(context.root), + ...[ + getReactNativeInitializeCore(context.root), + // `hmr/runtime` should import after `initializeCore` initialized. + context.enableHmr ? '@react-native-esbuild/hmr/runtime' : undefined, + ], ...(config?.injectScriptPaths ?? []), - ]; - - const reactNativeRuntimePreset = swcPresets.getReactNativeRuntimePreset( - context.config.transformer?.jsc, - ); + ].filter(Boolean) as string[]; + + const reactNativeRuntimePreset = swcPresets.getReactNativeRuntimePreset({ + experimental: { + hmr: context.enableHmr + ? { + runtime: false, + refreshReg: REACT_REFRESH_REGISTER_FUNCTION, + refreshSig: REACT_REFRESH_SIGNATURE_FUNCTION, + } + : undefined, + }, + }); const onBeforeTransform: AsyncTransformStep = async ( code, - args, + _args, moduleMeta, ) => { - const isChangedFile = bundlerSharedData.watcher.changed === args.path; const cacheConfig = { hash: moduleMeta.hash, mtimeMs: moduleMeta.stats.mtimeMs, }; - // 1. Force re-transform when file is changed. - if (isChangedFile) { - logger.debug('changed file detected', { path: args.path }); - return { code, done: false }; - } - /** - * 2. Use previous transformed result and skip transform + * 1. Use previous transformed result and skip transform * when file is not changed and transform result exist in memory. */ const inMemoryCache = getTransformedCodeFromInMemoryCache( @@ -75,12 +84,12 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr return { code: inMemoryCache, done: true }; } - // 3. Transform code on each build task when cache is disabled. + // 2. Transform code on each build task when cache is disabled. if (!cacheEnabled) { return { code, done: false }; } - // 4. Trying to get cache from file system. + // 3. Trying to get cache from file system. // = cache exist ? use cache : transform code const cachedCode = await getTransformedCodeFromFileSystemCache( cacheController, @@ -92,7 +101,7 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr const onAfterTransform: AsyncTransformStep = async ( code, - _args, + args, moduleMeta, ) => { const cacheConfig = { @@ -109,11 +118,16 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr ); } - return { code, done: true }; + return { + code: + context.enableHmr && HmrTransformer.isBoundary(args.path) + ? HmrTransformer.asBoundary(args.path, code) + : code, + done: true, + }; }; - let transformPipeline: AsyncTransformPipeline; - const transformPipelineBuilder = new AsyncTransformPipeline.builder(context) + const transformPipeline = new AsyncTransformPipeline.builder(context) .setSwcPreset(reactNativeRuntimePreset) .setInjectScripts(injectScriptPaths) .setFullyTransformPackages(fullyTransformPackageNames) @@ -121,18 +135,21 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr .setAdditionalBabelTransformRules(additionalBabelRules) .setAdditionalSwcTransformRules(additionalSwcRules) .onStart(onBeforeTransform) - .onEnd(onAfterTransform); + .onEnd(onAfterTransform) + .build(); - build.onStart(() => { - transformPipeline = transformPipelineBuilder.build(); - }); + context.enableHmr && jsonAsJsModule(build); build.onLoad({ filter: /\.(?:[mc]js|[tj]sx?)$/ }, async (args) => { const rawCode = await fs.readFile(args.path, { encoding: 'utf-8' }); return { - contents: (await transformPipeline.transform(rawCode, args)).code, + contents: ( + await transformPipeline.transform(rawCode, args, { + externalPattern: context.externalPattern, + }) + ).code, loader: 'js', - } as OnLoadResult; + }; }); build.onEnd(async (args) => { diff --git a/packages/plugins/lib/svgTransformPlugin/svgTransformPlugin.ts b/packages/plugins/lib/svgTransformPlugin/svgTransformPlugin.ts index 41005e78..db5751fb 100644 --- a/packages/plugins/lib/svgTransformPlugin/svgTransformPlugin.ts +++ b/packages/plugins/lib/svgTransformPlugin/svgTransformPlugin.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import { transform } from '@svgr/core'; import type { ReactNativeEsbuildPluginCreator } from '@react-native-esbuild/core'; +import { HmrTransformer } from '@react-native-esbuild/hmr'; const NAME = 'svg-transform-plugin'; @@ -13,15 +14,23 @@ export const createSvgTransformPlugin: ReactNativeEsbuildPluginCreator = ( build.onLoad({ filter: /\.svg$/ }, async (args) => { const rawSvg = await fs.readFile(args.path, { encoding: 'utf8' }); + const svgTransformedCode = await transform( + rawSvg, + { + plugins: ['@svgr/plugin-jsx'], + native: isNative, + }, + { filePath: args.path }, + ); + return { - contents: await transform( - rawSvg, - { - plugins: ['@svgr/plugin-jsx'], - native: isNative, - }, - { filePath: args.path }, - ), + contents: context.enableHmr + ? HmrTransformer.registerAsExternalModule( + args.path, + svgTransformedCode, + 'SvgLogo', + ) + : svgTransformedCode, loader: 'jsx', }; }); diff --git a/packages/plugins/package.json b/packages/plugins/package.json index aac9060c..946fc2a5 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -46,6 +46,7 @@ "dependencies": { "@react-native-esbuild/config": "workspace:*", "@react-native-esbuild/core": "workspace:*", + "@react-native-esbuild/hmr": "workspace:*", "@react-native-esbuild/internal": "workspace:*", "@react-native-esbuild/transformer": "workspace:*", "@react-native-esbuild/utils": "workspace:*", diff --git a/packages/transformer/README.md b/packages/transformer/README.md index 2982f484..29ac1ee5 100644 --- a/packages/transformer/README.md +++ b/packages/transformer/README.md @@ -7,11 +7,9 @@ import { stripFlowWithSucrase, transformWithBabel, transformWithSwc, - minifyWithSwc, } from '@react-native-esbuild/transformer'; await stripFlowWithSucrase(code, context); await transformWithBabel(code, context, options); await transformWithSwc(code, context, options); -await minifyWithSwc(code, context, options); ``` diff --git a/packages/transformer/lib/helpers/transformer.ts b/packages/transformer/lib/helpers/transformer.ts index 4e4877b3..daf06066 100644 --- a/packages/transformer/lib/helpers/transformer.ts +++ b/packages/transformer/lib/helpers/transformer.ts @@ -27,7 +27,10 @@ export const transformByBabelRule = ( context: TransformerContext, ): Promise => { return rule.test(context.path, code) - ? transformWithBabel(code, context, ruleOptionsToPreset(rule.options, code)) + ? transformWithBabel(code, { + context, + preset: ruleOptionsToPreset(rule.options, code), + }) : Promise.resolve(null); }; @@ -37,11 +40,10 @@ export const transformSyncByBabelRule = ( context: TransformerContext, ): string | null => { return rule.test(context.path, code) - ? transformSyncWithBabel( - code, + ? transformSyncWithBabel(code, { context, - ruleOptionsToPreset(rule.options, code), - ) + preset: ruleOptionsToPreset(rule.options, code), + }) : null; }; @@ -51,7 +53,10 @@ export const transformBySwcRule = ( context: TransformerContext, ): Promise => { return rule.test(context.path, code) - ? transformWithSwc(code, context, ruleOptionsToPreset(rule.options, code)) + ? transformWithSwc(code, { + context, + preset: ruleOptionsToPreset(rule.options, code), + }) : Promise.resolve(null); }; @@ -61,10 +66,9 @@ export const transformSyncBySwcRule = ( context: TransformerContext, ): string | null => { return rule.test(context.path, code) - ? transformSyncWithSwc( - code, + ? transformSyncWithSwc(code, { context, - ruleOptionsToPreset(rule.options, code), - ) + preset: ruleOptionsToPreset(rule.options, code), + }) : null; }; diff --git a/packages/transformer/lib/pipelines/AsyncTransformPipeline.ts b/packages/transformer/lib/pipelines/AsyncTransformPipeline.ts index e3330a0c..f9002b99 100644 --- a/packages/transformer/lib/pipelines/AsyncTransformPipeline.ts +++ b/packages/transformer/lib/pipelines/AsyncTransformPipeline.ts @@ -8,8 +8,8 @@ import { } from '../transformer'; import { transformByBabelRule, transformBySwcRule } from '../helpers'; import type { AsyncTransformStep, ModuleMeta, TransformResult } from '../types'; -import { TransformPipeline } from './pipeline'; -import { TransformPipelineBuilder } from './builder'; +import { TransformPipeline } from './TransformPipeline'; +import { TransformPipelineBuilder } from './TransformPipelineBuilder'; export class AsyncTransformPipelineBuilder extends TransformPipelineBuilder< AsyncTransformStep, @@ -40,14 +40,14 @@ export class AsyncTransformPipelineBuilder extends TransformPipelineBuilder< this.fullyTransformPackageNames, ); if (fullyTransformPackagesRegExp) { - pipeline.addStep(async (code, args) => { + pipeline.addStep(async (code, args, moduleMeta) => { if (fullyTransformPackagesRegExp.test(args.path)) { return { - code: await transformWithBabel( - code, - this.getContext(args), - this.presets.babelFullyTransform, - ), + code: await transformWithBabel(code, { + moduleMeta, + context: this.getContext(args), + preset: this.presets.babelFullyTransform, + }), // skip other transformations when fully transformed done: true, }; @@ -61,12 +61,15 @@ export class AsyncTransformPipelineBuilder extends TransformPipelineBuilder< this.stripFlowPackageNames, ); if (stripFlowPackageNamesRegExp) { - pipeline.addStep((code, args) => { + pipeline.addStep((code, args, moduleMeta) => { if ( stripFlowPackageNamesRegExp.test(args.path) || this.isFlow(code, args.path) ) { - code = stripFlowWithSucrase(code, this.getContext(args)); + code = stripFlowWithSucrase(code, { + moduleMeta, + context: this.getContext(args), + }); } return Promise.resolve({ code, done: false }); @@ -96,13 +99,13 @@ export class AsyncTransformPipelineBuilder extends TransformPipelineBuilder< } // 6. Transform code to es5. - pipeline.addStep(async (code, args) => { + pipeline.addStep(async (code, args, moduleMeta) => { return { - code: await transformWithSwc( - code, - this.getContext(args), - this.swcPreset, - ), + code: await transformWithSwc(code, { + moduleMeta, + context: this.getContext(args), + preset: this.swcPreset, + }), done: true, }; }); @@ -117,9 +120,14 @@ export class AsyncTransformPipeline extends TransformPipeline { + async transform( + code: string, + args: OnLoadArgs, + baseModuleMeta?: Pick, + ): Promise { const fileStat = await fs.stat(args.path); const moduleMeta: ModuleMeta = { + ...baseModuleMeta, stats: fileStat, hash: this.getHash(this.context.id, args.path, fileStat.mtimeMs), }; diff --git a/packages/transformer/lib/pipelines/SyncTransformPipeline.ts b/packages/transformer/lib/pipelines/SyncTransformPipeline.ts index 8eef2f51..edae191c 100644 --- a/packages/transformer/lib/pipelines/SyncTransformPipeline.ts +++ b/packages/transformer/lib/pipelines/SyncTransformPipeline.ts @@ -8,8 +8,8 @@ import { } from '../transformer'; import { transformSyncByBabelRule, transformSyncBySwcRule } from '../helpers'; import type { SyncTransformStep, TransformResult, ModuleMeta } from '../types'; -import { TransformPipeline } from './pipeline'; -import { TransformPipelineBuilder } from './builder'; +import { TransformPipeline } from './TransformPipeline'; +import { TransformPipelineBuilder } from './TransformPipelineBuilder'; export class SyncTransformPipelineBuilder extends TransformPipelineBuilder< SyncTransformStep, @@ -40,14 +40,14 @@ export class SyncTransformPipelineBuilder extends TransformPipelineBuilder< this.fullyTransformPackageNames, ); if (fullyTransformPackagesRegExp) { - pipeline.addStep((code, args) => { + pipeline.addStep((code, args, moduleMeta) => { if (fullyTransformPackagesRegExp.test(args.path)) { return { - code: transformSyncWithBabel( - code, - this.getContext(args), - this.presets.babelFullyTransform, - ), + code: transformSyncWithBabel(code, { + moduleMeta, + context: this.getContext(args), + preset: this.presets.babelFullyTransform, + }), // skip other transformations when fully transformed done: true, }; @@ -61,12 +61,15 @@ export class SyncTransformPipelineBuilder extends TransformPipelineBuilder< this.stripFlowPackageNames, ); if (stripFlowPackageNamesRegExp) { - pipeline.addStep((code, args) => { + pipeline.addStep((code, args, moduleMeta) => { if ( stripFlowPackageNamesRegExp.test(args.path) || this.isFlow(code, args.path) ) { - code = stripFlowWithSucrase(code, this.getContext(args)); + code = stripFlowWithSucrase(code, { + moduleMeta, + context: this.getContext(args), + }); } return { code, done: false }; @@ -96,9 +99,13 @@ export class SyncTransformPipelineBuilder extends TransformPipelineBuilder< } // 6. Transform code to es5. - pipeline.addStep((code, args) => { + pipeline.addStep((code, args, moduleMeta) => { return { - code: transformSyncWithSwc(code, this.getContext(args), this.swcPreset), + code: transformSyncWithSwc(code, { + moduleMeta, + context: this.getContext(args), + preset: this.swcPreset, + }), done: true, }; }); @@ -128,9 +135,14 @@ export class SyncTransformPipeline extends TransformPipeline return this; } - transform(code: string, args: OnLoadArgs): TransformResult { + transform( + code: string, + args: OnLoadArgs, + baseModuleMeta?: Pick, + ): TransformResult { const fileStat = fs.statSync(args.path); const moduleMeta: ModuleMeta = { + ...baseModuleMeta, stats: fileStat, hash: this.getHash(this.context.id, args.path, fileStat.mtimeMs), }; diff --git a/packages/transformer/lib/pipelines/pipeline.ts b/packages/transformer/lib/pipelines/TransformPipeline.ts similarity index 82% rename from packages/transformer/lib/pipelines/pipeline.ts rename to packages/transformer/lib/pipelines/TransformPipeline.ts index 84c84c3e..034ddec1 100644 --- a/packages/transformer/lib/pipelines/pipeline.ts +++ b/packages/transformer/lib/pipelines/TransformPipeline.ts @@ -1,6 +1,6 @@ import type { OnLoadArgs } from 'esbuild'; import md5 from 'md5'; -import type { TransformStep, TransformerContext } from '../types'; +import type { ModuleMeta, TransformStep, TransformerContext } from '../types'; export abstract class TransformPipeline> { protected steps: Step[] = []; @@ -36,5 +36,9 @@ export abstract class TransformPipeline> { return this; } - abstract transform(code: string, args: OnLoadArgs): ReturnType; + abstract transform( + code: string, + args: OnLoadArgs, + baseModuleMeta?: Pick, + ): ReturnType; } diff --git a/packages/transformer/lib/pipelines/builder.ts b/packages/transformer/lib/pipelines/TransformPipelineBuilder.ts similarity index 97% rename from packages/transformer/lib/pipelines/builder.ts rename to packages/transformer/lib/pipelines/TransformPipelineBuilder.ts index 366f9e28..c4374971 100644 --- a/packages/transformer/lib/pipelines/builder.ts +++ b/packages/transformer/lib/pipelines/TransformPipelineBuilder.ts @@ -8,7 +8,7 @@ import type { TransformerOptionsPreset, } from '../types'; import { babelPresets } from '../transformer'; -import type { TransformPipeline } from './pipeline'; +import type { TransformPipeline } from './TransformPipeline'; const FLOW_SYMBOL = ['@flow', '@noflow'] as const; diff --git a/packages/transformer/lib/transformer/babel/babel.ts b/packages/transformer/lib/transformer/babel/babel.ts index 95261d9c..a03c13ed 100644 --- a/packages/transformer/lib/transformer/babel/babel.ts +++ b/packages/transformer/lib/transformer/babel/babel.ts @@ -19,10 +19,9 @@ const loadBabelOptions = ( export const transformWithBabel: AsyncTransformer = async ( code: string, - context, - preset, + { context, moduleMeta, preset }, ) => { - const babelOptions = loadBabelOptions(context, preset?.(context)); + const babelOptions = loadBabelOptions(context, preset?.(context, moduleMeta)); if (!babelOptions) { throw new Error('cannot load babel options'); } @@ -37,10 +36,9 @@ export const transformWithBabel: AsyncTransformer = async ( export const transformSyncWithBabel: SyncTransformer = ( code: string, - context, - preset, + { context, moduleMeta, preset }, ) => { - const babelOptions = loadBabelOptions(context, preset?.(context)); + const babelOptions = loadBabelOptions(context, preset?.(context, moduleMeta)); if (!babelOptions) { throw new Error('cannot load babel options'); } diff --git a/packages/transformer/lib/transformer/sucrase/sucrase.ts b/packages/transformer/lib/transformer/sucrase/sucrase.ts index a0d5baf2..5fc752ee 100644 --- a/packages/transformer/lib/transformer/sucrase/sucrase.ts +++ b/packages/transformer/lib/transformer/sucrase/sucrase.ts @@ -10,7 +10,10 @@ const stripFlowTypeofImportStatements = (code: string): string => { .join('\n'); }; -export const stripFlowWithSucrase: SyncTransformer = (code, context) => { +export const stripFlowWithSucrase: SyncTransformer = ( + code, + { context }, +) => { return stripFlowTypeofImportStatements( transform(code, { transforms: TRANSFORM_FOR_STRIP_FLOW, diff --git a/packages/transformer/lib/transformer/swc/presets.ts b/packages/transformer/lib/transformer/swc/presets.ts index 62eb43f2..8978c00f 100644 --- a/packages/transformer/lib/transformer/swc/presets.ts +++ b/packages/transformer/lib/transformer/swc/presets.ts @@ -6,9 +6,22 @@ import type { } from '@swc/core'; import type { TransformerOptionsPreset, + ReactNativeRuntimePresetOptions, SwcJestPresetOptions, + ModuleMeta, + SwcMinifyPresetOptions, + TransformerContext, } from '../../types'; +/** + * TODO: move into hmr package. + * + * @see `HmrTransformer.isBoundary` + */ +const isHMRBoundary = (path: string): boolean => { + return !path.includes('/node_modules/') && !path.endsWith('runtime.js'); +}; + const getParserOptions = (path: string): TsParserConfig | EsParserConfig => { return /\.tsx?$/.test(path) ? ({ @@ -23,13 +36,35 @@ const getParserOptions = (path: string): TsParserConfig | EsParserConfig => { } as EsParserConfig); }; +const getSwcExperimental = ( + context: TransformerContext, + moduleMeta?: ModuleMeta, + options?: ReactNativeRuntimePresetOptions, +): JscConfig['experimental'] => { + if (options?.experimental?.hmr && isHMRBoundary(context.path)) { + return { + plugins: [ + [ + 'swc-plugin-global-module', + { + runtimeModule: options.experimental.hmr.runtime, + externalPattern: moduleMeta?.externalPattern, + importPaths: moduleMeta?.importPaths, + }, + ], + ], + }; + } + return undefined; +}; + /** * swc transform options preset for react-native runtime. */ const getReactNativeRuntimePreset = ( - jscConfig?: Pick, + options?: ReactNativeRuntimePresetOptions, ): TransformerOptionsPreset => { - return (context) => ({ + return (context, moduleMeta) => ({ minify: false, sourceMaps: false, isModule: true, @@ -39,10 +74,22 @@ const getReactNativeRuntimePreset = ( parser: getParserOptions(context.path), target: 'es5', loose: false, - externalHelpers: true, + externalHelpers: !context.dev, keepClassNames: true, - transform: jscConfig?.transform, - experimental: jscConfig?.experimental, + transform: { + react: { + development: context.dev, + // @ts-expect-error -- wrong type definition. + refresh: + options?.experimental?.hmr && isHMRBoundary(context.path) + ? { + refreshReg: options.experimental.hmr.refreshReg, + refreshSig: options.experimental.hmr.refreshSig, + } + : undefined, + }, + }, + experimental: getSwcExperimental(context, moduleMeta, options), }, filename: context.path, root: context.root, @@ -95,11 +142,21 @@ const getJestPreset = ( }); }; -const getMinifyPreset = () => { - return () => ({ - compress: true, - mangle: true, - sourceMap: false, +const getMinifyPreset = ({ + minify, +}: SwcMinifyPresetOptions): TransformerOptionsPreset => { + return (context) => ({ + minify, + inputSourceMap: false, + inlineSourcesContent: false, + jsc: { + parser: getParserOptions(context.path), + target: 'es5', + loose: false, + keepClassNames: true, + }, + filename: context.path, + root: context.root, }); }; diff --git a/packages/transformer/lib/transformer/swc/swc.ts b/packages/transformer/lib/transformer/swc/swc.ts index 9fe79f90..eb512f8c 100644 --- a/packages/transformer/lib/transformer/swc/swc.ts +++ b/packages/transformer/lib/transformer/swc/swc.ts @@ -1,18 +1,14 @@ -import { - transform, - transformSync, - minify, - type Options, - type JsMinifyOptions, -} from '@swc/core'; +import { transform, transformSync, type Options } from '@swc/core'; import type { AsyncTransformer, SyncTransformer } from '../../types'; export const transformWithSwc: AsyncTransformer = async ( code, - context, - preset, + { context, moduleMeta, preset }, ) => { - const { code: transformedCode } = await transform(code, preset?.(context)); + const { code: transformedCode } = await transform( + code, + preset?.(context, moduleMeta), + ); if (typeof transformedCode !== 'string') { throw new Error('swc transformed source is empty'); @@ -23,10 +19,12 @@ export const transformWithSwc: AsyncTransformer = async ( export const transformSyncWithSwc: SyncTransformer = ( code, - context, - preset, + { context, moduleMeta, preset }, ) => { - const { code: transformedCode } = transformSync(code, preset?.(context)); + const { code: transformedCode } = transformSync( + code, + preset?.(context, moduleMeta), + ); if (typeof transformedCode !== 'string') { throw new Error('swc transformed source is empty'); @@ -34,17 +32,3 @@ export const transformSyncWithSwc: SyncTransformer = ( return transformedCode; }; - -export const minifyWithSwc: AsyncTransformer = async ( - code, - context, - preset, -) => { - const { code: minifiedCode } = await minify(code, preset?.(context)); - - if (typeof minifiedCode !== 'string') { - throw new Error('swc minified source is empty'); - } - - return minifiedCode; -}; diff --git a/packages/transformer/lib/types.ts b/packages/transformer/lib/types.ts index 52591bbe..6ea2dc90 100644 --- a/packages/transformer/lib/types.ts +++ b/packages/transformer/lib/types.ts @@ -5,16 +5,20 @@ import type { Options as SwcTransformOptions } from '@swc/core'; export type AsyncTransformer = ( code: string, - context: TransformerContext, - preset?: TransformerOptionsPreset, + config: TransformerConfig, ) => Promise; export type SyncTransformer = ( code: string, - context: TransformerContext, - preset?: TransformerOptionsPreset, + config: TransformerConfig, ) => string; +interface TransformerConfig { + context: TransformerContext; + moduleMeta?: ModuleMeta; + preset?: TransformerOptionsPreset; +} + export interface TransformerContext { id: number; root: string; @@ -25,11 +29,30 @@ export interface TransformerContext { export type TransformerOptionsPreset = ( context: TransformerContext, + moduleMeta?: ModuleMeta, ) => TransformerOptions; -// swc preset options -export interface SwcReactNativeRuntimePresetOptions { - reactRefresh?: { moduleId: string }; +export interface ReactNativeRuntimePresetOptions { + /** + * Options for experimental features. + */ + experimental?: { + /** + * HMR(Hot Module Replacement) options. + * + * If `undefined`, HMR will be disabled. + */ + hmr?: { + /** + * `runtimeModule` option in `swc-plugin-global-module`. + * + * @see github {@link https://github.com/leegeunhyeok/swc-plugin-global-module} + */ + runtime: boolean; + refreshReg: string; + refreshSig: string; + }; + }; } export interface SwcJestPresetOptions { @@ -63,6 +86,10 @@ export interface SwcJestPresetOptions { }; } +export interface SwcMinifyPresetOptions { + minify: boolean; +} + export interface TransformRuleBase { /** * Predicator for transform @@ -95,4 +122,6 @@ export interface TransformResult { export interface ModuleMeta { hash: string; stats: Stats; + externalPattern?: string; + importPaths?: Record; } diff --git a/packages/transformer/package.json b/packages/transformer/package.json index 4c10fcb3..ecbed7df 100644 --- a/packages/transformer/package.json +++ b/packages/transformer/package.json @@ -49,6 +49,7 @@ "md5": "^2.3.0", "sucrase": "^3.34.0", "swc-plugin-coverage-instrument": "^0.0.20", + "swc-plugin-global-module": "^0.1.0-alpha.5", "swc_mut_cjs_exports": "^0.85.0" } } diff --git a/shared/index.js b/shared/index.js index 931f6fc5..33c3386d 100644 --- a/shared/index.js +++ b/shared/index.js @@ -1,15 +1,24 @@ const path = require('node:path'); /** - * @param {string} entryFile filename + * @param {string} packageDir build script directory * @param {import('esbuild').BuildOptions} options additional options * @returns {import('esbuild').BuildOptions} */ -exports.getEsbuildBaseOptions = (packageDir, options = {}) => ({ - entryPoints: [path.resolve(packageDir, '../lib/index.ts')], +const getEsbuildBaseOptions = (packageDir, options = {}) => ({ + entryPoints: [path.join(getPackageRoot(packageDir), 'lib/index.ts')], outfile: 'dist/index.js', bundle: true, platform: 'node', packages: 'external', ...options, }); + +/** + * @param {string} packageDir build script directory + * @returns package root path + */ +const getPackageRoot = (packageDir) => path.resolve(packageDir, '../'); + +exports.getEsbuildBaseOptions = getEsbuildBaseOptions; +exports.getPackageRoot = getPackageRoot; diff --git a/yarn.lock b/yarn.lock index 42602e43..7b219581 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3650,6 +3650,7 @@ __metadata: "@babel/core": ^7.23.2 "@faker-js/faker": ^8.1.0 "@react-native-esbuild/config": "workspace:*" + "@react-native-esbuild/hmr": "workspace:*" "@react-native-esbuild/internal": "workspace:*" "@react-native-esbuild/transformer": "workspace:*" "@react-native-esbuild/utils": "workspace:*" @@ -3661,6 +3662,7 @@ __metadata: ora: ^5.4.1 peerDependencies: react-native: "*" + swc-plugin-global-module: "*" languageName: unknown linkType: soft @@ -3672,6 +3674,7 @@ __metadata: "@react-native-community/cli-server-api": ^11.3.6 "@react-native-esbuild/config": "workspace:*" "@react-native-esbuild/core": "workspace:*" + "@react-native-esbuild/hmr": "workspace:*" "@react-native-esbuild/internal": "workspace:*" "@react-native-esbuild/symbolicate": "workspace:*" "@react-native-esbuild/utils": "workspace:*" @@ -3702,7 +3705,22 @@ __metadata: languageName: unknown linkType: soft -"@react-native-esbuild/internal@workspace:*, @react-native-esbuild/internal@workspace:packages/internal": +"@react-native-esbuild/hmr@workspace:*, @react-native-esbuild/hmr@workspace:packages/hmr": + version: 0.0.0-use.local + resolution: "@react-native-esbuild/hmr@workspace:packages/hmr" + dependencies: + "@react-native-esbuild/internal": "workspace:^" + "@react-native-esbuild/transformer": "workspace:^" + "@react-native-esbuild/utils": "workspace:*" + "@swc/helpers": ^0.5.2 + "@types/react-refresh": ^0.14.3 + esbuild: ^0.19.3 + react-refresh: ^0.14.0 + ws: ^8.14.2 + languageName: unknown + linkType: soft + +"@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: @@ -3736,6 +3754,7 @@ __metadata: "@faker-js/faker": ^8.1.0 "@react-native-esbuild/config": "workspace:*" "@react-native-esbuild/core": "workspace:*" + "@react-native-esbuild/hmr": "workspace:*" "@react-native-esbuild/internal": "workspace:*" "@react-native-esbuild/transformer": "workspace:*" "@react-native-esbuild/utils": "workspace:*" @@ -3761,7 +3780,7 @@ __metadata: languageName: unknown linkType: soft -"@react-native-esbuild/transformer@workspace:*, @react-native-esbuild/transformer@workspace:packages/transformer": +"@react-native-esbuild/transformer@workspace:*, @react-native-esbuild/transformer@workspace:^, @react-native-esbuild/transformer@workspace:packages/transformer": version: 0.0.0-use.local resolution: "@react-native-esbuild/transformer@workspace:packages/transformer" dependencies: @@ -3774,6 +3793,7 @@ __metadata: md5: ^2.3.0 sucrase: ^3.34.0 swc-plugin-coverage-instrument: ^0.0.20 + swc-plugin-global-module: ^0.1.0-alpha.5 swc_mut_cjs_exports: ^0.85.0 languageName: unknown linkType: soft @@ -4414,7 +4434,7 @@ __metadata: languageName: node linkType: hard -"@swc/helpers@npm:^0.5.3": +"@swc/helpers@npm:^0.5.2, @swc/helpers@npm:^0.5.3": version: 0.5.3 resolution: "@swc/helpers@npm:0.5.3" dependencies: @@ -4541,7 +4561,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.1.14": +"@types/babel__core@npm:*, @types/babel__core@npm:^7.1.14": version: 7.20.3 resolution: "@types/babel__core@npm:7.20.3" dependencies: @@ -4840,6 +4860,16 @@ __metadata: languageName: node linkType: hard +"@types/react-refresh@npm:^0.14.3": + version: 0.14.3 + resolution: "@types/react-refresh@npm:0.14.3" + dependencies: + "@types/babel__core": "*" + csstype: ^3.0.2 + checksum: ff48c18f928f213cfd1fa379f6b275e401adad87f909a83501a7026075a1d596ef2697269a9ce4fc5063841622ea2c2ffe9c332a2cc9e5ca67199f05c7994f89 + languageName: node + linkType: hard + "@types/react-test-renderer@npm:^18.0.5": version: 18.0.5 resolution: "@types/react-test-renderer@npm:18.0.5" @@ -8201,7 +8231,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.19.5": +"esbuild@npm:^0.19.3, esbuild@npm:^0.19.5": version: 0.19.5 resolution: "esbuild@npm:0.19.5" dependencies: @@ -17694,6 +17724,13 @@ __metadata: languageName: node linkType: hard +"swc-plugin-global-module@npm:^0.1.0-alpha.5": + version: 0.1.0-alpha.5 + resolution: "swc-plugin-global-module@npm:0.1.0-alpha.5" + checksum: 10523c54644544a989bdd77cf45a273f529d191816d8c5a4cb992267845048d66125e720a71bca49ae2ab47fcd051077f024d00cf4cf31030eeac1c67082ac18 + languageName: node + linkType: hard + "swc_mut_cjs_exports@npm:^0.85.0": version: 0.85.0 resolution: "swc_mut_cjs_exports@npm:0.85.0" @@ -19122,7 +19159,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.13.0": +"ws@npm:^8.13.0, ws@npm:^8.14.2": version: 8.14.2 resolution: "ws@npm:8.14.2" peerDependencies: