From df71c9acf263418ffc1d04ced0f621182d06b9dd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 25 May 2026 10:24:57 +0200 Subject: [PATCH] feat: support Metro platform extensions for runtime entries Move Metro integration files under packages/core/metro and extract runtime entry filename handling with Bun tests. --- .../__tests__/runtime-entry-files.test.js | 88 ++++++++++++++ .../core/{metro.d.ts => metro/index.d.ts} | 4 + packages/core/{metro.js => metro/index.js} | 115 ++++++++++++++---- packages/core/metro/runtime-entry-files.js | 74 +++++++++++ .../transformer.js} | 2 +- packages/core/package.json | 9 +- 6 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 packages/core/metro/__tests__/runtime-entry-files.test.js rename packages/core/{metro.d.ts => metro/index.d.ts} (87%) rename packages/core/{metro.js => metro/index.js} (84%) create mode 100644 packages/core/metro/runtime-entry-files.js rename packages/core/{metro-transformer.js => metro/transformer.js} (83%) diff --git a/packages/core/metro/__tests__/runtime-entry-files.test.js b/packages/core/metro/__tests__/runtime-entry-files.test.js new file mode 100644 index 0000000..7b02bc0 --- /dev/null +++ b/packages/core/metro/__tests__/runtime-entry-files.test.js @@ -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']); + }); +}); diff --git a/packages/core/metro.d.ts b/packages/core/metro/index.d.ts similarity index 87% rename from packages/core/metro.d.ts rename to packages/core/metro/index.d.ts index 1c9f3aa..3994a2b 100644 --- a/packages/core/metro.d.ts +++ b/packages/core/metro/index.d.ts @@ -1,8 +1,10 @@ type ThreadedRuntimeMetroOptions = { generatedDir?: string; generatedEntry?: string; + platformExtensions?: string[]; projectRoot?: string; roots?: string[]; + sourceExtensions?: string[]; }; type ThreadedRuntimeComponentRegistration = { @@ -24,8 +26,10 @@ type RuntimeFunctionRegistration = { export function generateThreadedRuntimeEntry(options: { generatedEntry: string; + platformExtensions?: string[]; projectRoot?: string; roots?: string[]; + sourceExtensions?: string[]; }): { components: ThreadedRuntimeComponentRegistration[]; generatedEntry: string; diff --git a/packages/core/metro.js b/packages/core/metro/index.js similarity index 84% rename from packages/core/metro.js rename to packages/core/metro/index.js index c6e8ca2..b35e21d 100644 --- a/packages/core/metro.js +++ b/packages/core/metro/index.js @@ -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', @@ -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 = {}) { @@ -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:', @@ -51,14 +65,20 @@ 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]), @@ -66,7 +86,13 @@ function withThreadedRuntime(config, options = {}) { }; } -function watchSources({ projectRoot, roots, onChange }) { +function watchSources({ + projectRoot, + roots, + onChange, + platformExtensions, + sourceExtensions, +}) { let timeout = null; const schedule = () => { if (timeout) { @@ -113,7 +139,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); @@ -125,7 +153,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); }); }); @@ -149,16 +177,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(); @@ -211,30 +244,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 => { @@ -244,35 +297,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, { @@ -611,7 +669,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 ( diff --git a/packages/core/metro/runtime-entry-files.js b/packages/core/metro/runtime-entry-files.js new file mode 100644 index 0000000..358a386 --- /dev/null +++ b/packages/core/metro/runtime-entry-files.js @@ -0,0 +1,74 @@ +const DEFAULT_SOURCE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']; + +function getPlatformExtensionsFromMetroConfig(config) { + const platformExtensions = normalizeExtensions( + config?.resolver?.platforms || [], + ); + if ( + config?.resolver?.preferNativePlatform !== false && + !platformExtensions.includes('native') + ) { + platformExtensions.push('native'); + } + return platformExtensions; +} + +function isRuntimeEntryFileName(fileName, options = {}) { + return runtimeEntryFromFileName(fileName, options) !== null; +} + +function runtimeEntryFromFileName(fileName, options = {}) { + const platformExtensions = new Set( + normalizeExtensions(options.platformExtensions || []), + ); + const sourceExtensions = new Set( + normalizeExtensions(options.sourceExtensions || DEFAULT_SOURCE_EXTENSIONS), + ); + const baseName = fileName.split(/[\\/]/).pop(); + const parts = baseName.split('.'); + + if (parts[0] !== 'index' || parts.length < 3) { + return null; + } + + const sourceExtension = parts.pop(); + if (!sourceExtensions.has(sourceExtension)) { + return null; + } + + let platformExtension = null; + const lastNameSegment = parts[parts.length - 1]; + if (platformExtensions.has(lastNameSegment)) { + platformExtension = parts.pop(); + } + + if (parts.length !== 2) { + return null; + } + + const runtimeName = parts[1]; + if (!runtimeName || platformExtensions.has(runtimeName)) { + return null; + } + + return { + platformExtension, + requestBaseName: `index.${runtimeName}`, + runtimeName, + sourceExtension, + }; +} + +function normalizeExtensions(extensions) { + return Array.from(extensions) + .map(extension => String(extension).replace(/^\./, '')) + .filter(Boolean); +} + +module.exports = { + DEFAULT_SOURCE_EXTENSIONS, + getPlatformExtensionsFromMetroConfig, + isRuntimeEntryFileName, + normalizeExtensions, + runtimeEntryFromFileName, +}; diff --git a/packages/core/metro-transformer.js b/packages/core/metro/transformer.js similarity index 83% rename from packages/core/metro-transformer.js rename to packages/core/metro/transformer.js index 71fb566..92dd1c1 100644 --- a/packages/core/metro-transformer.js +++ b/packages/core/metro/transformer.js @@ -1,5 +1,5 @@ const upstreamTransformer = require('@react-native/metro-babel-transformer'); -const runtimeFunctionPlugin = require('./runtime-function-babel-plugin'); +const runtimeFunctionPlugin = require('../runtime-function-babel-plugin'); function transform(params) { const plugins = [ diff --git a/packages/core/package.json b/packages/core/package.json index 57aeb5e..04394eb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,18 +9,19 @@ "android", "cpp", "ios", - "metro.d.ts", - "metro.js", - "metro-transformer.js", + "metro", "NativeComposeThreadedRuntime.podspec", "README.md", "runtime-function-babel-plugin.js", "src" ], + "scripts": { + "test": "bun test metro/__tests__" + }, "typesVersions": { "*": { "metro": [ - "metro.d.ts" + "metro/index.d.ts" ] } },