Skip to content

Commit

Permalink
performance: using custom watcher instead esbuild.watch
Browse files Browse the repository at this point in the history
- `chokidar` based custom watcher
  - watch root dir and sub directories
  - watch resolving extension files
  - ignore local cache directory
- enhance transform plugin
  - re-transform only changed file
  • Loading branch information
leegeunhyeok committed Oct 16, 2023
1 parent 64a6a02 commit df3855f
Show file tree
Hide file tree
Showing 9 changed files with 221 additions and 63 deletions.
30 changes: 28 additions & 2 deletions packages/core/lib/bundler/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@react-native-esbuild/config';
import { Logger, LogLevel } from '@react-native-esbuild/utils';
import { CacheStorage } from '../cache';
import { FileSystemWatcher } from '../watcher';
import { logger } from '../shared';
import type {
Config,
Expand All @@ -28,6 +29,7 @@ import type {
EsbuildPluginFactory,
PluginContext,
ReportableEvent,
BundlerSharedData,
} from '../types';
import {
loadConfig,
Expand All @@ -45,6 +47,11 @@ import { printLogo, printVersion } from './logo';

export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
public static caches = CacheStorage.getInstance();
public static shared: BundlerSharedData = {
watcher: {
changed: null,
},
};
private config: Config;
private appLogger = new Logger('app', LogLevel.Trace);
private buildTasks = new Map<number, BuildTask>();
Expand Down Expand Up @@ -84,6 +91,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
this.on('report', (event) => {
this.broadcastToReporter(event);
});
this.setupWatcher();
}

private broadcastToReporter(event: ReportableEvent): void {
Expand Down Expand Up @@ -112,6 +120,23 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
this.config.reporter?.(event);
}

private setupWatcher(): void {
FileSystemWatcher.getInstance()
.setHandler((changedFile, stats) => {
if (this.buildTasks.size > 0) {
ReactNativeEsbuildBundler.shared.watcher = {
changed: changedFile,
stats,
};
}

for (const { context } of this.buildTasks.values()) {
context.rebuild();
}
})
.watch(this.root);
}

private async getBuildOptionsForBundler(
mode: BundleMode,
bundleOptions: BundleOptions,
Expand Down Expand Up @@ -299,8 +324,9 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter {
status: 'pending',
buildCount: 0,
});
await context.watch();
logger.debug(`bundle task is now watching: (id: ${targetTaskId})`);
// trigger first build
context.rebuild();
logger.debug(`bundle task is now watching (id: ${targetTaskId})`);
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- set() if not exist
Expand Down
8 changes: 8 additions & 0 deletions packages/core/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Stats } from 'node:fs';
import type { BuildContext, Plugin } from 'esbuild';
import type { TransformOptions as BabelTransformOptions } from '@babel/core';
import type { Options as SwcTransformOptions } from '@swc/core';
Expand Down Expand Up @@ -155,6 +156,13 @@ export type EsbuildPluginFactory<PluginConfig = void> = (
config?: PluginConfig,
) => (context: PluginContext) => Plugin;

export interface BundlerSharedData {
watcher: {
changed: string | null;
stats?: Stats;
};
}

export type BundlerAdditionalData = Record<string, unknown>;

export interface PluginContext extends BundleOptions {
Expand Down
79 changes: 79 additions & 0 deletions packages/core/lib/watcher/FileSystemWatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import path from 'node:path';
import type { Stats } from 'node:fs';
import * as chokidar from 'chokidar';
import {
SOURCE_EXTENSIONS,
ASSET_EXTENSIONS,
} from '@react-native-esbuild/internal';
import { LOCAL_CACHE_DIR } from '@react-native-esbuild/config';
import { logger } from '../shared';

const WATCH_EXTENSIONS_REGEXP = new RegExp(
`(?:${[...SOURCE_EXTENSIONS, ...ASSET_EXTENSIONS].join('|')})$`,
);

export class FileSystemWatcher {
private static instance: FileSystemWatcher | null = null;
private watcher: chokidar.FSWatcher | null = null;
private onWatch?: (path: string, stats?: Stats) => void;

private constructor() {
// empty constructor
}

public static getInstance(): FileSystemWatcher {
if (FileSystemWatcher.instance === null) {
FileSystemWatcher.instance = new FileSystemWatcher();
}
return FileSystemWatcher.instance;
}

private handleWatch(path: string, stats?: Stats): void {
logger.debug('event received from watcher', { path });
if (!WATCH_EXTENSIONS_REGEXP.test(path)) {
return;
}
this.onWatch?.(path, stats);
}

setHandler(handler: (path: string, stats?: Stats) => void): this {
this.onWatch = handler;
return this;
}

watch(targetPath: string): void {
if (this.watcher) {
logger.debug('already watching');
return;
}

const ignoreDirectories = [path.join(targetPath, LOCAL_CACHE_DIR)];

this.watcher = chokidar
.watch(targetPath, {
alwaysStat: true,
ignoreInitial: true,
ignored: ignoreDirectories,
})
.on('add', this.handleWatch.bind(this) as typeof this.handleWatch)
.on('addDir', this.handleWatch.bind(this) as typeof this.handleWatch)
.on('change', this.handleWatch.bind(this) as typeof this.handleWatch)
.on('unlink', this.handleWatch.bind(this) as typeof this.handleWatch)
.on('unlinkDir', this.handleWatch.bind(this) as typeof this.handleWatch)
.on('ready', () => {
logger.debug(`watching '${targetPath}'`);
})
.on('error', (error) => {
logger.error('unexpected error on watcher', error);
});
}

close(): void {
if (!this.watcher) {
logger.debug('not watching');
return;
}
this.watcher.close();
this.watcher = null;
}
}
1 change: 1 addition & 0 deletions packages/core/lib/watcher/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './FileSystemWatcher';
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 @@
"@react-native-esbuild/internal": "workspace:*",
"@react-native-esbuild/transformer": "workspace:*",
"@react-native-esbuild/utils": "workspace:*",
"chokidar": "^3.5.3",
"deepmerge": "^4.3.1",
"esbuild": "^0.19.3",
"md5": "^2.3.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,73 +1,66 @@
import fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
import type { OnLoadArgs } from 'esbuild';
import type {
CacheController,
PluginContext,
} from '@react-native-esbuild/core';
import type { CacheConfig } from '../../types';

export const getTransformedCodeFromCache = async (
export const makeCacheConfig = async (
controller: CacheController,
args: OnLoadArgs,
context: PluginContext,
): Promise<{
code: string | null;
hash: string;
mtimeMs: number;
}> => {
stats?: Stats,
): Promise<CacheConfig> => {
/**
* `id` is combined value (platform, dev, minify)
* use `id` as filesystem hash key generation
*
* md5(id + modified time + file path) = cache key
* number + number + string
* number + number + string
*/
const { mtimeMs } = await fs.stat(args.path);
const hash = controller.getCacheHash(context.id + mtimeMs + args.path);
const inMemoryCache = controller.readFromMemory(hash);

const makeReturnValue = (
data: string | null,
): {
code: string | null;
hash: string;
mtimeMs: number;
} => {
return { code: data, hash, mtimeMs };
const mtimeMs = (stats ?? (await fs.stat(args.path))).mtimeMs;
return {
hash: controller.getCacheHash(context.id + mtimeMs + args.path),
mtimeMs,
};
};

// 1. find cache from memory
if (inMemoryCache) {
if (inMemoryCache.modifiedAt === mtimeMs) {
// file is not modified, using cache data
return makeReturnValue(inMemoryCache.data);
}
return makeReturnValue(null);
}

const fsCache = await controller.readFromFileSystem(hash);

// 2. find cache from file system
if (fsCache) {
controller.writeToMemory(hash, {
data: fsCache,
modifiedAt: mtimeMs,
});
return makeReturnValue(fsCache);
}
export const getTransformedCodeFromInMemoryCache = (
controller: CacheController,
cacheConfig: CacheConfig,
): string | null => {
const inMemoryCache = controller.readFromMemory(cacheConfig.hash);
// file is not modified, using cache data
return inMemoryCache && inMemoryCache.modifiedAt === cacheConfig.mtimeMs
? inMemoryCache.data
: null;
};

// 3. cache not found
return makeReturnValue(null);
export const getTransformedCodeFromFileSystemCache = async (
controller: CacheController,
cacheConfig: CacheConfig,
): Promise<string | null> => {
const fsCache = await controller.readFromFileSystem(cacheConfig.hash);
return fsCache ?? null;
};

export const writeTransformedCodeToCache = async (
export const writeTransformedCodeToInMemoryCache = (
controller: CacheController,
code: string,
hash: string,
mtimeMs: number,
): Promise<void> => {
controller.writeToMemory(hash, {
cacheConfig: CacheConfig,
): void => {
controller.writeToMemory(cacheConfig.hash, {
data: code,
modifiedAt: mtimeMs,
modifiedAt: cacheConfig.mtimeMs,
});
await controller.writeToFileSystem(hash, code);
};

export const writeTransformedCodeToFileSystemCache = (
controller: CacheController,
code: string,
cacheConfig: CacheConfig,
): Promise<void> => {
return controller.writeToFileSystem(cacheConfig.hash, code);
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import {
type FlowRunner,
} from './TransformFlowBuilder';
import {
getTransformedCodeFromCache,
writeTransformedCodeToCache,
makeCacheConfig,
getTransformedCodeFromInMemoryCache,
getTransformedCodeFromFileSystemCache,
writeTransformedCodeToInMemoryCache,
writeTransformedCodeToFileSystemCache,
} from './helpers';

const NAME = 'react-native-runtime-transform-plugin';
Expand Down Expand Up @@ -46,29 +49,70 @@ export const createReactNativeRuntimeTransformPlugin: EsbuildPluginFactory<
args,
sharedData,
) => {
if (!cacheEnabled) return { code, done: false };
const isChangedFile = Bundler.shared.watcher.changed === args.path;
const cacheConfig = await makeCacheConfig(
cacheController,
args,
context,
isChangedFile ? Bundler.shared.watcher.stats : undefined,
);

const {
code: cachedCode,
hash,
mtimeMs,
} = await getTransformedCodeFromCache(cacheController, args, context);
sharedData.hash = cacheConfig.hash;
sharedData.mtimeMs = cacheConfig.mtimeMs;

sharedData.hash = hash;
sharedData.mtimeMs = mtimeMs;
// 1. force re-transform when file changed
if (isChangedFile) {
logger.debug('changed file detected', { path: args.path });
return { code, done: false };
}

// 2. using previous transformed result and skip transform
// when file is not changed and transform result in memory
const inMemoryCache = getTransformedCodeFromInMemoryCache(
cacheController,
cacheConfig,
);
if (inMemoryCache) {
return { code: inMemoryCache, done: true };
}

// 3. when cache is disabled, always transform code each build tasks
if (!cacheEnabled) {
return { code, done: false };
}

// 4. trying to get cache from file system
// = cache exist ? use cache : transform code
const cachedCode = await getTransformedCodeFromFileSystemCache(
cacheController,
cacheConfig,
);

return { code: cachedCode ?? code, done: Boolean(cachedCode) };
};

const onAfterTransform: FlowRunner = async (code, _args, shared) => {
if (cacheEnabled && shared.hash && shared.mtimeMs) {
await writeTransformedCodeToCache(
if (!(shared.hash && shared.mtimeMs)) {
logger.warn('unexpected cache config');
return { code, done: true };
}

const cacheConfig = { hash: shared.hash, mtimeMs: shared.mtimeMs };

writeTransformedCodeToInMemoryCache(
cacheController,
code,
cacheConfig,
);

if (cacheEnabled) {
await writeTransformedCodeToFileSystemCache(
cacheController,
code,
shared.hash,
shared.mtimeMs,
cacheConfig,
);
}

return { code, done: true };
};

Expand Down
Loading

1 comment on commit df3855f

@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.76% 358/2271
🔴 Branches 18.82% 140/744
🔴 Functions 10.91% 71/651
🔴 Lines 14.97% 314/2097

Test suite run success

98 tests passing in 14 suites.

Report generated by 🧪jest coverage report action from df3855f

Please sign in to comment.