Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 112 additions & 35 deletions bun.lock

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions packages/core/metro/__tests__/runtime-entry-files.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const { describe, expect, test } = require('bun:test');
const {
getPlatformExtensionsFromMetroConfig,
isRuntimeEntryFileName,
runtimeEntryFromFileName,
} = require('../runtime-entry-files');

describe('runtime entry file names', () => {
const metroOptions = {
platformExtensions: ['android', 'ios', 'native'],
sourceExtensions: ['js', 'jsx', 'ts', 'tsx'],
};

test('uses index.[runtime].[source] files as runtime entries', () => {
expect(runtimeEntryFromFileName('index.business.js', metroOptions)).toEqual({
platformExtension: null,
requestBaseName: 'index.business',
runtimeName: 'business',
sourceExtension: 'js',
});
expect(isRuntimeEntryFileName('index.worker.ts', metroOptions)).toBe(true);
});

test('ignores configured platform extensions as runtime names', () => {
expect(runtimeEntryFromFileName('index.ios.js', metroOptions)).toBeNull();
expect(runtimeEntryFromFileName('index.android.ts', metroOptions)).toBeNull();
expect(runtimeEntryFromFileName('index.native.tsx', metroOptions)).toBeNull();
});

test('strips configured platform extensions from platform-specific runtime entries', () => {
expect(runtimeEntryFromFileName('index.business.ios.ts', metroOptions)).toEqual(
{
platformExtension: 'ios',
requestBaseName: 'index.business',
runtimeName: 'business',
sourceExtension: 'ts',
},
);
});

test('rejects files that are not direct index runtime entries', () => {
expect(runtimeEntryFromFileName('App.tsx', metroOptions)).toBeNull();
expect(runtimeEntryFromFileName('index.js', metroOptions)).toBeNull();
expect(runtimeEntryFromFileName('runtime.business.ts', metroOptions)).toBeNull();
expect(runtimeEntryFromFileName('index.business.extra.ts', metroOptions)).toBeNull();
});

test('honors configured source extensions', () => {
expect(
runtimeEntryFromFileName('index.business.mjs', {
platformExtensions: [],
sourceExtensions: ['mjs'],
}),
).toEqual({
platformExtension: null,
requestBaseName: 'index.business',
runtimeName: 'business',
sourceExtension: 'mjs',
});
expect(runtimeEntryFromFileName('index.business.ts', {
platformExtensions: [],
sourceExtensions: ['mjs'],
})).toBeNull();
});
});

describe('Metro config extraction', () => {
test('extracts configured platform extensions from Metro resolver config', () => {
expect(
getPlatformExtensionsFromMetroConfig({
resolver: {
platforms: ['ios', 'android'],
},
}),
).toEqual(['ios', 'android', 'native']);
});

test('allows native platform extension extraction to be disabled', () => {
expect(
getPlatformExtensionsFromMetroConfig({
resolver: {
platforms: ['ios', 'android'],
preferNativePlatform: false,
},
}),
).toEqual(['ios', 'android']);
});
});
4 changes: 4 additions & 0 deletions packages/core/metro.d.ts → packages/core/metro/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
type ThreadedRuntimeMetroOptions = {
generatedDir?: string;
generatedEntry?: string;
platformExtensions?: string[];
projectRoot?: string;
roots?: string[];
sourceExtensions?: string[];
};

type ThreadedRuntimeComponentRegistration = {
Expand All @@ -24,8 +26,10 @@ type RuntimeFunctionRegistration = {

export function generateThreadedRuntimeEntry(options: {
generatedEntry: string;
platformExtensions?: string[];
projectRoot?: string;
roots?: string[];
sourceExtensions?: string[];
}): {
components: ThreadedRuntimeComponentRegistration[];
generatedEntry: string;
Expand Down
115 changes: 88 additions & 27 deletions packages/core/metro.js → packages/core/metro/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const {
DEFAULT_SOURCE_EXTENSIONS,
getPlatformExtensionsFromMetroConfig,
isRuntimeEntryFileName,
normalizeExtensions,
runtimeEntryFromFileName,
} = require('./runtime-entry-files');

const DEFAULT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
const DEFAULT_IGNORED_DIRS = new Set([
'.git',
'.gradle',
Expand All @@ -20,7 +26,6 @@ const IGNORED_FUNCTION_DIRECTIVES = new Set([
'worklet',
]);

const RUNTIME_ENTRY_PATTERN = /^index\.[^.]+\.ts$/;
const WATCH_DEBOUNCE_MS = 50;

function withThreadedRuntime(config, options = {}) {
Expand All @@ -36,10 +41,19 @@ function withThreadedRuntime(config, options = {}) {
options.generatedEntry || 'entry.js',
);
const roots = options.roots || ['App.tsx', 'src'];
const platformExtensions =
options.platformExtensions || getPlatformExtensionsFromMetroConfig(config);
const sourceExtensions = options.sourceExtensions || DEFAULT_SOURCE_EXTENSIONS;

const regenerate = () => {
try {
generateThreadedRuntimeEntry({ generatedEntry, projectRoot, roots });
generateThreadedRuntimeEntry({
generatedEntry,
platformExtensions,
projectRoot,
roots,
sourceExtensions,
});
} catch (error) {
console.error(
'[threaded-runtime] failed to regenerate entry:',
Expand All @@ -51,22 +65,34 @@ function withThreadedRuntime(config, options = {}) {
regenerate();

if (options.watch !== false) {
watchSources({ projectRoot, roots, onChange: regenerate });
watchSources({
onChange: regenerate,
platformExtensions,
projectRoot,
roots,
sourceExtensions,
});
}

return {
...config,
transformer: {
...(config.transformer || {}),
babelTransformerPath: path.join(__dirname, 'metro-transformer.js'),
babelTransformerPath: path.join(__dirname, 'transformer.js'),
},
watchFolders: Array.from(
new Set([...(config.watchFolders || []), generatedDir]),
),
};
}

function watchSources({ projectRoot, roots, onChange }) {
function watchSources({
projectRoot,
roots,
onChange,
platformExtensions,
sourceExtensions,
}) {
let timeout = null;
const schedule = () => {
if (timeout) {
Expand Down Expand Up @@ -116,7 +142,9 @@ function watchSources({ projectRoot, roots, onChange }) {
});

watchDir(projectRoot, false, filename => {
if (RUNTIME_ENTRY_PATTERN.test(filename)) {
if (
isRuntimeEntryFileName(filename, { platformExtensions, sourceExtensions })
) {
return true;
}
return fileRootsAtProjectRoot.has(filename);
Expand All @@ -128,7 +156,7 @@ function watchSources({ projectRoot, roots, onChange }) {
if (parts.some(part => DEFAULT_IGNORED_DIRS.has(part))) {
return false;
}
return DEFAULT_EXTENSIONS.has(path.extname(filename));
return hasSourceExtension(filename, sourceExtensions);
});
});

Expand All @@ -152,16 +180,21 @@ function watchSources({ projectRoot, roots, onChange }) {

function generateThreadedRuntimeEntry({
generatedEntry,
platformExtensions = [],
projectRoot = process.cwd(),
roots = ['App.tsx', 'src'],
sourceExtensions = DEFAULT_SOURCE_EXTENSIONS,
}) {
const root = path.resolve(projectRoot);
const files = collectSourceFiles(root, roots);
const files = collectSourceFiles(root, roots, sourceExtensions);
const components = files.flatMap(file => scanThreadedComponents(file, root));
const runtimeFunctions = files.flatMap(file =>
scanRuntimeFunctions(file, root),
);
const runtimeEntries = collectRuntimeEntryFiles(root);
const runtimeEntries = collectRuntimeEntryFiles(root, {
platformExtensions,
sourceExtensions,
});
const seenNames = new Map();
const seenRuntimeFunctionIds = new Map();

Expand Down Expand Up @@ -214,30 +247,50 @@ function generateThreadedRuntimeEntry({
};
}

function collectRuntimeEntryFiles(projectRoot) {
function collectRuntimeEntryFiles(
projectRoot,
{ platformExtensions, sourceExtensions } = {},
) {
if (!fs.existsSync(projectRoot)) {
return [];
}

return fs
const entriesByRuntimeName = new Map();

fs
.readdirSync(projectRoot, { withFileTypes: true })
.filter(entry => entry.isFile())
.map(entry => entry.name)
.map(fileName => {
const match = /^index\.([^.]+)\.ts$/.exec(fileName);
if (!match) {
return null;
.sort()
.forEach(fileName => {
const runtimeEntry = runtimeEntryFromFileName(fileName, {
platformExtensions,
sourceExtensions,
});
if (!runtimeEntry) {
return;
}
return {

const entry = {
file: path.join(projectRoot, fileName),
runtimeName: match[1],
platformExtension: runtimeEntry.platformExtension,
requestFile: path.join(
projectRoot,
`${runtimeEntry.requestBaseName}.${runtimeEntry.sourceExtension}`,
),
runtimeName: runtimeEntry.runtimeName,
};
})
.filter(Boolean)
const existing = entriesByRuntimeName.get(runtimeEntry.runtimeName);
if (!existing || (existing.platformExtension && !entry.platformExtension)) {
entriesByRuntimeName.set(runtimeEntry.runtimeName, entry);
}
});

return Array.from(entriesByRuntimeName.values())
.sort((left, right) => left.runtimeName.localeCompare(right.runtimeName));
}

function collectSourceFiles(projectRoot, roots) {
function collectSourceFiles(projectRoot, roots, sourceExtensions) {
const files = [];

roots.forEach(rootPath => {
Expand All @@ -247,35 +300,40 @@ function collectSourceFiles(projectRoot, roots) {
}
const stat = fs.statSync(absoluteRoot);
if (stat.isFile()) {
if (DEFAULT_EXTENSIONS.has(path.extname(absoluteRoot))) {
if (hasSourceExtension(absoluteRoot, sourceExtensions)) {
files.push(absoluteRoot);
}
return;
}
if (stat.isDirectory()) {
walkDirectory(absoluteRoot, files);
walkDirectory(absoluteRoot, files, sourceExtensions);
}
});

return files.sort();
}

function walkDirectory(directory, files) {
function walkDirectory(directory, files, sourceExtensions) {
fs.readdirSync(directory, { withFileTypes: true }).forEach(entry => {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (!DEFAULT_IGNORED_DIRS.has(entry.name)) {
walkDirectory(absolutePath, files);
walkDirectory(absolutePath, files, sourceExtensions);
}
return;
}

if (entry.isFile() && DEFAULT_EXTENSIONS.has(path.extname(entry.name))) {
if (entry.isFile() && hasSourceExtension(entry.name, sourceExtensions)) {
files.push(absolutePath);
}
});
}

function hasSourceExtension(fileName, sourceExtensions) {
const extension = path.extname(fileName).replace(/^\./, '');
return new Set(normalizeExtensions(sourceExtensions)).has(extension);
}

function scanThreadedComponents(file, projectRoot) {
const source = fs.readFileSync(file, 'utf8');
const ast = parser.parse(source, {
Expand Down Expand Up @@ -614,7 +672,10 @@ function renderRuntimeEntryDispatch({ generatedDir, runtimeEntries }) {

const branches = runtimeEntries
.map((entry, index) => {
const requestPath = toRequirePath(generatedDir, entry.file);
const requestPath = toRequirePath(
generatedDir,
entry.requestFile || entry.file,
);
const keyword = index === 0 ? 'if' : 'else if';
const runtimeName = JSON.stringify(entry.runtimeName);
return (
Expand Down
Loading