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 Nov 20, 2023
1 parent 9b59d54 commit c250fad
Show file tree
Hide file tree
Showing 40 changed files with 850 additions and 179 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.
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.
28 changes: 21 additions & 7 deletions packages/core/lib/bundler/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
BundleResult,
BundleRequestOptions,
PluginContext,
UpdatedModule,
ReportableEvent,
ReactNativeEsbuildPluginCreator,
} from '../types';
Expand All @@ -35,18 +36,19 @@ import { createBuildStatusPlugin, createMetafilePlugin } from './plugins';
import { BundlerEventEmitter } from './events';
import {
loadConfig,
getConfigFromGlobal,
createPromiseHandler,
getConfigFromGlobal,
getTransformedPreludeScript,
getResolveExtensionsOption,
getLoaderOption,
getEsbuildWebConfig,
getHmrUpdatedModule,
} 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 appLogger = new Logger('app', LogLevel.Trace);
private buildTasks = new Map<number, BuildTask>();
private plugins: ReactNativeEsbuildPluginCreator<unknown>[] = [];
Expand Down Expand Up @@ -133,10 +135,11 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
return FileSystemWatcher.getInstance()
.setHandler((event, changedFile, stats) => {
const hasTask = this.buildTasks.size > 0;
const isChanged = event === 'change';
ReactNativeEsbuildBundler.shared.setValue({
watcher: {
changed: hasTask && event === 'change' ? changedFile : null,
stats,
changed: hasTask && isChanged ? changedFile : null,
stats: stats ?? null,
},
});

Expand All @@ -153,12 +156,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.sourceExtensions, 'invalid sourceExtensions');

setEnvironment(bundleOptions.dev);

const webSpecifiedOptions =
Expand Down Expand Up @@ -207,7 +210,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 Down Expand Up @@ -267,13 +270,17 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
process.exit(1);
}

const hmrSharedValue = ReactNativeEsbuildBundler.shared.get(context.id);
const currentTask = this.buildTasks.get(context.id);
invariant(hmrSharedValue, 'invalid hmr shared value');
invariant(currentTask, 'no task');

const bundleEndedAt = new Date();
const bundleFilename = context.outfile;
const bundleSourcemapFilename = `${bundleFilename}.map`;
const revisionId = bundleEndedAt.getTime().toString();
const { outputFiles } = data.result;
let updatedModule: UpdatedModule | null = null;

const findFromOutputFile = (
filename: string,
Expand All @@ -293,6 +300,12 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
invariant(bundleOutput, 'empty bundle output');
invariant(bundleSourcemapOutput, 'empty sourcemap output');

updatedModule = getHmrUpdatedModule(
hmrSharedValue.hmr.id,
hmrSharedValue.hmr.path,
bundleOutput.text,
);

currentTask.handler?.resolver?.({
result: {
source: bundleOutput.contents,
Expand All @@ -310,6 +323,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
revisionId,
id: context.id,
additionalData: context.additionalData,
updatedModule,
});
}
}
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
Expand Up @@ -3,6 +3,7 @@ import type {
BundlerAdditionalData,
BuildStatus,
ReportableEvent,
UpdatedModule,
} from '../../types';

export class BundlerEventEmitter extends EventEmitter {
Expand Down Expand Up @@ -46,6 +47,7 @@ export interface BundlerEventPayload {
'build-end': {
id: number;
revisionId: string;
updatedModule: UpdatedModule | null;
additionalData?: BundlerAdditionalData;
};
'build-status-change': BuildStatus & {
Expand Down
25 changes: 25 additions & 0 deletions packages/core/lib/bundler/helpers/hmr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {
getModuleCodeFromBundle,
isReactRefreshRegistered,
} from '@react-native-esbuild/hmr';
import type { UpdatedModule } from '../../types';

export const getHmrUpdatedModule = (
id: string | null,
path: string | null,
bundleCode: string,
): UpdatedModule | null => {
const updatedCode =
id && path ? getModuleCodeFromBundle(bundleCode, id) : null;

return updatedCode
? {
code: updatedCode,
id: id ?? '',
path: path ?? '',
mode: isReactRefreshRegistered(updatedCode)
? 'hot-reload'
: 'full-reload',
}
: null;
};
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 './hmr';
export * from './internal';
20 changes: 18 additions & 2 deletions packages/core/lib/bundler/plugins/statusPlugin/StatusLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,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;
Expand All @@ -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)
Expand Down Expand Up @@ -102,6 +109,7 @@ export class StatusLogger {
this.previousPercent = 0;
this.statusUpdate();

process.stdout.write('\n');
isTTY()
? this.spinner.start()
: this.print(`${this.platformText} build in progress...`);
Expand All @@ -110,12 +118,20 @@ export class StatusLogger {
async summary({ warnings, errors }: BuildResult): Promise<boolean> {
const duration = (new Date().getTime() - this.buildStartedAt) / 1000;
const isSuccess = errors.length === 0;
const changedFileText =
isSuccess && this.bundlerSharedData.hmr.path
? colors.gray(
`(${this.bundlerSharedData.hmr.path
.replace(this.context.root, '')
.substring(1)})`,
)
: '';

await this.printMessages(warnings, 'warning');
await this.printMessages(errors, 'error');

const resultText = isSuccess
? `${this.platformText} done!`
? `${this.platformText} done! ${changedFileText}`
: `${this.platformText} failed!`;

if (isTTY()) {
Expand Down
11 changes: 10 additions & 1 deletion packages/core/lib/bundler/storages/CacheStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ import { Storage } from './Storage';
const CACHE_DIRECTORY = path.join(os.tmpdir(), GLOBAL_CACHE_DIR);

export class CacheStorage extends Storage<CacheController> {
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);
Expand Down
25 changes: 20 additions & 5 deletions packages/core/lib/bundler/storages/SharedStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ import type { BundlerSharedData } from '../../types';
import { Storage } from './Storage';

export class SharedStorage extends Storage<BundlerSharedData> {
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,
},
watcher: { changed: null, stats: null },
hmr: { id: null, path: null },
};
}

Expand All @@ -34,8 +45,12 @@ export class SharedStorage extends Storage<BundlerSharedData> {

public clearAll(): Promise<void> {
for (const sharedData of this.data.values()) {
// watcher
sharedData.watcher.changed = null;
sharedData.watcher.stats = undefined;
sharedData.watcher.stats = null;
// hmr
sharedData.hmr.id = null;
sharedData.hmr.path = null;
}
return Promise.resolve();
}
Expand Down
12 changes: 11 additions & 1 deletion packages/core/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,21 @@ export type ReactNativeEsbuildPluginCreator<PluginConfig = void> = (
export interface BundlerSharedData {
watcher: {
changed: string | null;
stats?: Stats;
stats: Stats | null;
};
hmr: {
id: string | null;
path: string | null;
};
}

export type BundlerAdditionalData = Record<string, unknown>;
export interface UpdatedModule {
id: string;
path: string;
code: string;
mode: 'hot-reload' | 'full-reload';
}

export interface PluginContext extends BundleOptions {
id: number;
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
Loading

1 comment on commit c250fad

@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

St.
Category Percentage Covered / Total
🔴 Statements 15.38% 328/2133
🔴 Branches 14.67% 82/559
🔴 Functions 10.22% 65/636
🔴 Lines 14.72% 298/2025

Test suite run success

83 tests passing in 10 suites.

Report generated by 🧪jest coverage report action from c250fad

Please sign in to comment.