Skip to content

Commit

Permalink
feat: implements hot reload
Browse files Browse the repository at this point in the history
- supports hot reloading with `react-refresh`
  - currently, only React component can be hot reload
- add hot reload runtime script and inject into entry

close #38
  • Loading branch information
leegeunhyeok committed Dec 14, 2023
1 parent a1eb801 commit ecb071d
Show file tree
Hide file tree
Showing 60 changed files with 1,548 additions and 376 deletions.
11 changes: 11 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Binary file not shown.
Binary file not shown.
3 changes: 3 additions & 0 deletions example/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ yarn-error.log
!.yarn/sdks
!.yarn/versions

# @swc
.swc

# @react-native-esbuild
.rne
.swc
Expand Down
Binary file not shown.
4 changes: 2 additions & 2 deletions example/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AppRegistry } from 'react-native';
import { App } from './src/App';
import { name as appName } from './app.json';
// import { name as appName } from './app.json';

AppRegistry.registerComponent(appName, () => App);
AppRegistry.registerComponent('example', () => App);
1 change: 1 addition & 0 deletions example/metafile-ios-1701305959515.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion example/src/screens/IntroScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function IntroScreen(): React.ReactElement {
<Button label="GitHub" onPress={handlePressGitHub} />
</Section>
<Section title="Experimental">
<Description>This project is under development.</Description>
<Description>This project is under development</Description>
<Text variant="danger">CHECK & TEST BEFORE USING IN PRODUCTION</Text>
</Section>
<View sx={{ marginBottom: '$04' }} />
Expand Down
113 changes: 88 additions & 25 deletions packages/core/lib/bundler/bundler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from 'node:path';
import type { Stats } from 'node:fs';
import esbuild, {
type BuildOptions,
type BuildResult,
Expand All @@ -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,
Expand Down Expand Up @@ -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<number, HmrTransformer>();
private appLogger = new Logger('app', LogLevel.Trace);
private buildTasks = new Map<number, BuildTask>();
private plugins: ReactNativeEsbuildPluginCreator<unknown>[] = [];
private initialized = false;
private config: Config;
private external: string[];
private externalPattern: string;
private initialized = false;

/**
* Must be bootstrapped first at the entry point
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -131,19 +143,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {

private startWatcher(): Promise<void> {
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);
}

Expand All @@ -153,12 +153,12 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
additionalData?: BundlerAdditionalData,
): Promise<BuildOptions> {
const config = this.config;
const enableHmr = bundleOptions.dev && !bundleOptions.minify;
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 webSpecifiedOptions =
Expand All @@ -176,14 +176,15 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
id: this.identifyTaskByBundleOptions(bundleOptions),
root: this.root,
config: this.config,
externalPattern: this.externalPattern,
mode,
additionalData,
};

return {
entryPoints: [bundleOptions.entry],
outfile: bundleOptions.outfile,
sourceRoot: path.dirname(bundleOptions.entry),
sourceRoot: this.root,
mainFields: config.resolver.mainFields,
resolveExtensions: getResolveExtensionsOption(
bundleOptions,
Expand All @@ -207,7 +208,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
// Additional plugins in configuration.
...(config.plugins ?? []),
],
legalComments: bundleOptions.dev ? 'inline' : 'none',
legalComments: enableHmr ? 'inline' : 'none',
target: 'es6',
format: 'esm',
supported: {
Expand All @@ -225,8 +226,8 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
logLevel: 'silent',
bundle: true,
sourcemap: true,
metafile: true,
minify: bundleOptions.minify,
metafile: bundleOptions.metafile,
write: mode === 'bundle',
...webSpecifiedOptions,
};
Expand Down Expand Up @@ -257,6 +258,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.
*
Expand All @@ -267,8 +270,14 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
process.exit(1);
}

const hmrController = ReactNativeEsbuildBundler.hmr.get(context.id);
const sharedStorage = ReactNativeEsbuildBundler.shared.get(context.id);
const currentTask = this.buildTasks.get(context.id);

invariant(sharedStorage, 'invalid shared storage');
invariant(hmrController, 'no hmr controller');
invariant(currentTask, 'no task');

const bundleEndedAt = new Date();
const bundleFilename = context.outfile;
const bundleSourcemapFilename = `${bundleFilename}.map`;
Expand All @@ -293,6 +302,9 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
invariant(bundleOutput, 'empty bundle output');
invariant(bundleSourcemapOutput, 'empty sourcemap output');

const bundleMeta = HmrTransformer.createBundleMeta(data.result.metafile);
ReactNativeEsbuildBundler.shared.setValue({ bundleMeta });

currentTask.handler?.resolver?.({
result: {
source: bundleOutput.contents,
Expand All @@ -310,16 +322,41 @@ 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;

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(),
});
});
}
}

private async getOrCreateBundleTask(
private async getOrSetupTask(
bundleOptions: BundleOptions,
additionalData?: BundlerAdditionalData,
): Promise<BuildTask> {
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(
Expand All @@ -340,6 +377,32 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
logger.debug(`bundle task is now watching (id: ${targetTaskId})`);
}

// HMR Transformer
if (!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)!;
}
Expand Down Expand Up @@ -426,7 +489,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,
);
Expand All @@ -442,7 +505,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
additionalData?: BundlerAdditionalData,
): Promise<BundleResult> {
this.throwIfNotInitialized();
const buildTask = await this.getOrCreateBundleTask(
const buildTask = await this.getOrSetupTask(
combineWithDefaultBundleOptions(bundleOptions),
additionalData,
);
Expand Down
2 changes: 2 additions & 0 deletions packages/core/lib/bundler/events/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import EventEmitter from 'node:events';
import type { BundleUpdate } from '@react-native-esbuild/hmr';
import type {
BundlerAdditionalData,
BuildStatus,
Expand Down Expand Up @@ -46,6 +47,7 @@ export interface BundlerEventPayload {
'build-end': {
id: number;
revisionId: string;
update: BundleUpdate | null;
additionalData?: BundlerAdditionalData;
};
'build-status-change': BuildStatus & {
Expand Down
7 changes: 0 additions & 7 deletions packages/core/lib/bundler/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,6 @@ export const loadConfig = (configFilePath?: string): Config => {
assetExtensions: ASSET_EXTENSIONS,
},
transformer: {
jsc: {
transform: {
react: {
runtime: 'automatic',
},
},
},
stripFlowPackageNames: ['react-native'],
},
web: {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/lib/bundler/helpers/fs.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>),
];
};
1 change: 1 addition & 0 deletions packages/core/lib/bundler/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './async';
export * from './config';
export * from './fs';
export * from './internal';
Loading

1 comment on commit ecb071d

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage report

❌ An unexpected error occurred. For more details, check console

Error: The process '/opt/hostedtoolcache/node/18.19.0/x64/bin/npx' failed with exit code 1
St.
Category Percentage Covered / Total
🔴 Statements 14.84% 328/2210
🔴 Branches 14.21% 82/577
🔴 Functions 9.85% 65/660
🔴 Lines 14.18% 298/2101

Test suite run failed

Failed tests: 1/83. Failed suites: 1/10.
  ● getAssetRegistrationScript › should match snapshot

    expect(received).toMatchSnapshot()

    Snapshot name: `getAssetRegistrationScript should match snapshot 1`

    - Snapshot  - 3
    + Received  + 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});
    -   "
    + "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});"

      19 |
      20 |   it('should match snapshot', () => {
    > 21 |     expect(assetRegistrationScript).toMatchSnapshot();
         |                                     ^
      22 |   });
      23 | });
      24 |

      at Object.toMatchSnapshot (packages/internal/lib/__tests__/presets.test.ts:21:37)

Report generated by 🧪jest coverage report action from ecb071d

Please sign in to comment.