diff --git a/packages/react-native-builder-bob/package.json b/packages/react-native-builder-bob/package.json index 350998d05..a1d4844ec 100644 --- a/packages/react-native-builder-bob/package.json +++ b/packages/react-native-builder-bob/package.json @@ -51,6 +51,7 @@ "@babel/preset-env": "^7.29.2", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", + "@jridgewell/sourcemap-codec": "^1.5.5", "arktype": "^2.2.0", "babel-plugin-syntax-hermes-parser": "^0.34.0", "browserslist": "^4.28.2", diff --git a/packages/react-native-builder-bob/src/__tests__/resolveModuleSpecifier.test.ts b/packages/react-native-builder-bob/src/__tests__/resolveModuleSpecifier.test.ts new file mode 100644 index 000000000..38b4982f3 --- /dev/null +++ b/packages/react-native-builder-bob/src/__tests__/resolveModuleSpecifier.test.ts @@ -0,0 +1,150 @@ +import nodeFs from 'node:fs'; +import path from 'node:path'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { afterEach, expect, test, vi } from 'vitest'; +import { resolveModuleSpecifier } from '../utils/resolveModuleSpecifier.ts'; + +const jsExtensions = ['ts', 'tsx', 'js', 'jsx'].map((source) => ({ + source, + output: 'js', +})); + +const explicitJsExtensions = ['ts', 'tsx'].map((source) => ({ + source, + output: 'js', +})); + +const declarationExtensions = [{ source: 'd.ts', output: 'js' }]; + +const explicitDeclarationExtensions = ['ts', 'tsx'].map((source) => ({ + source, + emitted: declarationExtensions, +})); + +afterEach(() => { + vi.restoreAllMocks(); + mockFs.restore(); +}); + +const preserveThrowIfNoEntry = () => { + const lstatSync = nodeFs.lstatSync; + + vi.spyOn(nodeFs, 'lstatSync').mockImplementation((filename, options) => { + try { + return lstatSync(filename, options); + } catch (error) { + if ( + options != null && + typeof options === 'object' && + 'throwIfNoEntry' in options && + options.throwIfNoEntry === false && + error != null && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return undefined; + } + + throw error; + } + }); +}; + +const resolve = async ( + files: string[], + specifier: string, + mode: 'js' | 'declaration' = 'js' +) => { + const root = path.resolve('/project'); + + mockFs({ + [root]: {}, + }); + preserveThrowIfNoEntry(); + + await Promise.all( + files.map(async (file) => fs.outputFile(path.join(root, file), '')) + ); + + return resolveModuleSpecifier({ + filepath: path.join(root, 'src/index.ts'), + specifier, + extensions: mode === 'js' ? jsExtensions : declarationExtensions, + explicitExtensions: + mode === 'js' ? explicitJsExtensions : explicitDeclarationExtensions, + }); +}; + +test('adds output extensions to relative module specifiers', async () => { + expect(await resolve(['src/foo.ts'], './foo')).toBe('./foo.js'); +}); + +test('keeps non-relative module specifiers unchanged', async () => { + expect(await resolve([], 'react')).toBe('react'); +}); + +test('replaces explicit source extensions', async () => { + expect(await resolve(['src/foo.ts'], './foo.ts')).toBe('./foo.js'); +}); + +test('expands folder imports to index files', async () => { + expect(await resolve(['src/foo/index.ts'], './foo')).toBe('./foo/index.js'); +}); + +test('keeps imports extensionless when a platform-specific file exists', async () => { + expect(await resolve(['src/foo.ts', 'src/foo.ios.ts'], './foo')).toBe( + './foo' + ); +}); + +test('keeps folder imports extensionless when a platform-specific index file exists', async () => { + expect( + await resolve(['src/foo/index.ts', 'src/foo/index.ios.ts'], './foo') + ).toBe('./foo'); +}); + +test('replaces explicit source extensions when an emitted file exists', async () => { + expect(await resolve(['src/foo.d.ts'], './foo.ts', 'declaration')).toBe( + './foo.js' + ); +}); + +test('keeps explicit mts source extensions unchanged', async () => { + expect(await resolve(['src/foo.d.mts'], './foo.mts', 'declaration')).toBe( + './foo.mts' + ); +}); + +test('keeps explicit cts source extensions unchanged', async () => { + expect(await resolve(['src/foo.d.cts'], './foo.cts', 'declaration')).toBe( + './foo.cts' + ); +}); + +test('keeps declaration imports extensionless when a platform-specific declaration exists', async () => { + expect( + await resolve(['src/foo.d.ts', 'src/foo.ios.d.ts'], './foo', 'declaration') + ).toBe('./foo'); +}); + +test('keeps declaration folder imports extensionless when a platform-specific index declaration exists', async () => { + expect( + await resolve( + ['src/foo/index.d.ts', 'src/foo/index.ios.d.ts'], + './foo', + 'declaration' + ) + ).toBe('./foo'); +}); + +test('replaces explicit source declaration imports even when a platform-specific declaration exists', async () => { + expect( + await resolve( + ['src/foo.d.ts', 'src/foo.ios.d.ts'], + './foo.ts', + 'declaration' + ) + ).toBe('./foo.js'); +}); diff --git a/packages/react-native-builder-bob/src/__tests__/typescript-declarations.test.ts b/packages/react-native-builder-bob/src/__tests__/typescript-declarations.test.ts new file mode 100644 index 000000000..5891e0eb3 --- /dev/null +++ b/packages/react-native-builder-bob/src/__tests__/typescript-declarations.test.ts @@ -0,0 +1,269 @@ +import os from 'node:os'; +import path from 'node:path'; +import { decode } from '@jridgewell/sourcemap-codec'; +import fs from 'fs-extra'; +import { expect, test, vi } from 'vitest'; +import build from '../targets/typescript.ts'; +import type { Report } from '../types.ts'; +import { spawn } from '../utils/spawn.ts'; + +const tsc = path.resolve( + import.meta.dirname, + '../../../../node_modules/.bin', + process.platform === 'win32' ? 'tsc.cmd' : 'tsc' +); + +const report: Report = { + info: vi.fn(), + warn: vi.fn(), + success: vi.fn(), + error: vi.fn(), +}; + +const readDeclarationMap = async (filepath: string) => { + const value: unknown = await fs.readJSON(filepath); + + if ( + value == null || + typeof value !== 'object' || + !('mappings' in value) || + typeof value.mappings !== 'string' || + !('sources' in value) || + !Array.isArray(value.sources) || + !value.sources.every((source) => typeof source === 'string') + ) { + throw new Error('Invalid declaration map.'); + } + + return { + mappings: value.mappings, + sources: value.sources, + }; +}; + +const buildLibrary = async ( + root: string, + { + files, + compilerOptions, + packageJson, + }: { + files: Record; + compilerOptions?: Record; + packageJson?: Record; + } +) => { + await fs.writeJSON(path.join(root, 'package.json'), { + name: 'library', + version: '1.0.0', + type: 'module', + exports: { + '.': { + types: './lib/typescript/src/index.d.ts', + }, + }, + ...packageJson, + }); + + await fs.writeJSON(path.join(root, 'tsconfig.json'), { + compilerOptions: { + module: 'ESNext', + moduleResolution: 'Bundler', + rootDir: '.', + strict: true, + target: 'ESNext', + ...compilerOptions, + }, + include: ['src/**/*'], + }); + + await Promise.all( + Object.entries(files).map(async ([name, content]) => + fs.outputFile(path.join(root, name), content) + ) + ); + + await build({ + root, + source: path.join(root, 'src'), + output: path.join(root, 'lib/typescript'), + report, + options: { project: 'tsconfig.json', tsc }, + esm: true, + variants: { module: true }, + }); +}; + +const typeCheckConsumer = async (root: string, index: string) => { + const consumer = path.join(root, 'consumer'); + + await fs.ensureSymlink(root, path.join(consumer, 'node_modules/library')); + await fs.writeJSON(path.join(consumer, 'package.json'), { + type: 'module', + }); + await fs.writeJSON(path.join(consumer, 'tsconfig.json'), { + compilerOptions: { + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + target: 'ESNext', + }, + include: ['index.ts'], + }); + await fs.outputFile(path.join(consumer, 'index.ts'), index); + + try { + await spawn(tsc, ['--noEmit', '--project', 'tsconfig.json'], { + cwd: consumer, + }); + + return undefined; + } catch (error) { + if ( + error != null && + typeof error === 'object' && + 'stdout' in error && + typeof error.stdout === 'string' + ) { + return error.stdout; + } + + throw error; + } +}; + +test('adds extensions to declarations for NodeNext resolution', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-')); + + try { + await buildLibrary(root, { + compilerOptions: { allowImportingTsExtensions: true }, + files: { + 'src/index.ts': [ + "export { type Foo } from './foo';", + "export * from './star';", + "export { type Bar } from './nested';", + "export { type Baz } from './explicit.ts';", + "export type UsesImportType = import('./foo').Foo;", + ].join('\n'), + 'src/foo.ts': 'export type Foo = { value: string };\n', + 'src/star.ts': 'export type Star = { value: string };\n', + 'src/nested/index.ts': 'export type Bar = { value: string };\n', + 'src/explicit.ts': 'export type Baz = { value: string };\n', + }, + }); + + const declaration = await fs.readFile( + path.join(root, 'lib/typescript/src/index.d.ts'), + 'utf-8' + ); + + expect(declaration).toContain("from './foo.js'"); + expect(declaration).toContain("from './star.js'"); + expect(declaration).toContain("from './nested/index.js'"); + expect(declaration).toContain("from './explicit.js'"); + expect(declaration).toContain("import('./foo.js').Foo"); + + const declarationMap = await readDeclarationMap( + path.join(root, 'lib/typescript/src/index.d.ts.map') + ); + + expect(declarationMap.mappings).toEqual(expect.any(String)); + expect( + declarationMap.sources.some((source) => source.endsWith('/src/index.ts')) + ).toBe(true); + + const firstLine = declaration.split('\n')[0]; + const firstLineColumns = decode(declarationMap.mappings)[0]?.map( + (segment) => segment[0] + ); + + expect(firstLineColumns).toContain(firstLine?.indexOf(';')); + + const stdout = await typeCheckConsumer( + root, + [ + "import type { Bar, Baz, Foo, Star, UsesImportType } from 'library';", + '', + 'type Test = [Bar, Baz, Foo, Star, UsesImportType];', + ].join('\n') + ); + + expect(stdout).toBeUndefined(); + } finally { + await fs.remove(root); + } +}); + +test('keeps codegen spec imports unchanged', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-')); + + try { + await buildLibrary(root, { + packageJson: { + codegenConfig: { name: 'Lib', type: 'all', jsSrcsDir: 'src' }, + }, + files: { + 'src/index.ts': [ + "export { default as Foo } from './FooNativeComponent';", + "export { type Bar } from './bar';", + ].join('\n'), + 'src/FooNativeComponent.ts': [ + 'declare function codegenNativeComponent(name: string): T;', + 'export type FooProps = { value: string };', + "export default codegenNativeComponent('Foo');", + ].join('\n'), + 'src/bar.ts': 'export type Bar = { value: string };\n', + }, + }); + + const declaration = await fs.readFile( + path.join(root, 'lib/typescript/src/index.d.ts'), + 'utf-8' + ); + + expect(declaration).toContain("from './FooNativeComponent'"); + expect(declaration).not.toContain("from './FooNativeComponent.js'"); + // Non-codegen imports are still rewritten as usual + expect(declaration).toContain("from './bar.js'"); + } finally { + await fs.remove(root); + } +}); + +test('keeps platform-specific declaration imports extensionless', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-')); + + try { + await buildLibrary(root, { + files: { + 'src/index.ts': "export { type Platform } from './platform';\n", + 'src/platform.ts': 'export type Platform = { value: string };\n', + 'src/platform.ios.ts': 'export type Platform = { value: string };\n', + }, + }); + + const declaration = await fs.readFile( + path.join(root, 'lib/typescript/src/index.d.ts'), + 'utf-8' + ); + + expect(declaration).toContain("from './platform'"); + expect(declaration).not.toContain("from './platform.js'"); + + const stdout = await typeCheckConsumer( + root, + [ + "import type { Platform } from 'library';", + '', + 'type Test = Platform;', + ].join('\n') + ); + + expect(stdout).toContain( + 'Relative import paths need explicit file extensions' + ); + } finally { + await fs.remove(root); + } +}); diff --git a/packages/react-native-builder-bob/src/__tests__/updateSourceMap.test.ts b/packages/react-native-builder-bob/src/__tests__/updateSourceMap.test.ts new file mode 100644 index 000000000..5c2126c79 --- /dev/null +++ b/packages/react-native-builder-bob/src/__tests__/updateSourceMap.test.ts @@ -0,0 +1,129 @@ +import path from 'node:path'; +import { encode } from '@jridgewell/sourcemap-codec'; +import mockFs from 'mock-fs'; +import fs from 'fs-extra'; +import ts from 'typescript'; +import { afterEach, expect, test } from 'vitest'; +import { type Replacement, updateSourceMap } from '../utils/updateSourceMap.ts'; + +const mappings = (lines: number[][]) => + encode(lines.map((columns) => columns.map((column): [number] => [column]))); + +const readMap = async (filepath: string) => { + const map: unknown = await fs.readJSON(filepath); + + if ( + map == null || + typeof map !== 'object' || + !('mappings' in map) || + typeof map.mappings !== 'string' + ) { + throw new Error('Invalid source map.'); + } + + return map; +}; + +afterEach(() => { + mockFs.restore(); +}); + +// Write a map with the given mappings, run updateSourceMap, then return the result. +const update = async ( + generated: string, + replacements: Replacement[], + code = 'x'.repeat(100) +) => { + const root = path.resolve('/project'); + const filepath = path.join(root, 'index.d.ts.map'); + const sourceFile = ts.createSourceFile( + 'index.d.ts', + code, + ts.ScriptTarget.Latest, + true + ); + + mockFs({ + [root]: {}, + }); + + await fs.writeJSON(filepath, { + version: 3, + file: 'index.d.ts', + sourceRoot: '', + sources: ['index.ts'], + names: ['Name'], + mappings: generated, + sourcesContent: ['source'], + }); + + await updateSourceMap({ filepath, replacements, sourceFile }); + + return await readMap(filepath); +}; + +test('updates mappings for multiple replacements on the same line', async () => { + const map = await update(mappings([[0, 17, 21, 40]]), [ + { start: 10, end: 15, value: 'x'.repeat(8) }, + { start: 30, end: 35, value: 'x'.repeat(8) }, + ]); + + expect(map.mappings).toBe(mappings([[0, 20, 24, 46]])); +}); + +test('shifts mappings at the end of the replacement range', async () => { + const map = await update(mappings([[0, 12, 15, 20]]), [ + { start: 10, end: 15, value: 'x'.repeat(8) }, + ]); + + expect(map.mappings).toBe(mappings([[0, 12, 18, 23]])); +}); + +test('updates only the line containing the replacement', async () => { + const map = await update( + mappings([ + [0, 20], + [0, 20], + ]), + [{ start: 41, end: 46, value: 'x'.repeat(8) }], + `${'x'.repeat(30)}\n${'x'.repeat(30)}` + ); + + expect(map.mappings).toBe( + mappings([ + [0, 20], + [0, 23], + ]) + ); +}); + +test('throws for replacements that span multiple lines', async () => { + const generated = mappings([ + [0, 5], + [0, 5], + ]); + + await expect( + update( + generated, + [{ start: 3, end: 8, value: 'x'.repeat(10) }], + 'xxxxx\nxxxxx' + ) + ).rejects.toThrow( + 'Source map replacement spanning multiple lines is not supported.' + ); +}); + +test('preserves source map metadata', async () => { + const map = await update(mappings([[0, 20]]), [ + { start: 10, end: 15, value: 'x'.repeat(8) }, + ]); + + expect(map).toMatchObject({ + file: 'index.d.ts', + names: ['Name'], + sourceRoot: '', + sources: ['index.ts'], + sourcesContent: ['source'], + }); +}); diff --git a/packages/react-native-builder-bob/src/babel.ts b/packages/react-native-builder-bob/src/babel.ts index e6477db52..6ca63d34a 100644 --- a/packages/react-native-builder-bob/src/babel.ts +++ b/packages/react-native-builder-bob/src/babel.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import type { ConfigAPI, NodePath, PluginObj, PluginPass } from '@babel/core'; import type { @@ -7,6 +6,10 @@ import type { ExportNamedDeclaration, } from '@babel/types'; import { isCodegenSpec } from './utils/isCodegenSpec.ts'; +import { + resolveModuleSpecifier, + SOURCE_EXTENSIONS, +} from './utils/resolveModuleSpecifier.ts'; type Options = { /** @@ -25,36 +28,6 @@ type Options = { platforms?: string[]; }; -const extensions = ['ts', 'tsx', 'js', 'jsx']; - -const isFile = (filename: string): boolean => { - const exists = - fs.lstatSync(filename, { throwIfNoEntry: false })?.isFile() ?? false; - - return exists; -}; - -const isDirectory = (filename: string): boolean => { - const exists = - fs.lstatSync(filename, { throwIfNoEntry: false })?.isDirectory() ?? false; - - return exists; -}; - -const isModuleWithoutPlatform = ( - filename: string, - extension: string, - platforms: string[] -): boolean => { - const exts = [...extensions, extension]; - - return exts.some( - (ext) => - isFile(`${filename}.${ext}`) && - platforms.every((platform) => !isFile(`${filename}.${platform}.${ext}`)) - ); -}; - const isTypeImport = ( node: ImportDeclaration | ExportNamedDeclaration | ExportAllDeclaration ) => @@ -71,26 +44,23 @@ const assertFilename: ( export default function ( api: ConfigAPI, - { - extension, - platforms = [ - 'native', - 'android', - 'ios', - 'windows', - 'macos', - 'visionos', - 'web', - 'tv', - 'android.tv', - 'ios.tv', - ], - }: Options + { extension, platforms }: Options ): PluginObj { api.assertVersion(7); const codegenEnabled = api.caller((caller) => caller?.codegenEnabled); + const toExtensions = (sources: string[]) => + extension == null + ? [] + : sources.map((source) => ({ source, output: extension })); + + const rewriteExtensions = toExtensions( + extension ? [...SOURCE_EXTENSIONS, extension] : SOURCE_EXTENSIONS + ); + + const explicitRewriteExtensions = toExtensions(['ts', 'tsx']); + function addExtension( { node, @@ -120,39 +90,18 @@ export default function ( if ( codegenEnabled && (isCodegenSpec(filename) || - extensions.some((ext) => isCodegenSpec(`${filename}.${ext}`))) + SOURCE_EXTENSIONS.some((ext) => isCodegenSpec(`${filename}.${ext}`))) ) { return; } - // Replace .ts extension with .js if file with extension is explicitly imported - if (isFile(filename)) { - node.source.value = node.source.value.replace(/\.tsx?$/, `.${extension}`); - return; - } - - // Add extension if .ts file or file with extension exists - // And no platform specific file exists - if (isModuleWithoutPlatform(filename, extension, platforms)) { - node.source.value += `.${extension}`; - return; - } - - // Expand folder imports to index and add extension - if ( - isDirectory(filename) && - isModuleWithoutPlatform( - path.join(filename, 'index'), - extension, - platforms - ) - ) { - node.source.value = node.source.value.replace( - /\/?$/, - `/index.${extension}` - ); - return; - } + node.source.value = resolveModuleSpecifier({ + filepath: state.filename, + specifier: node.source.value, + extensions: rewriteExtensions, + explicitExtensions: explicitRewriteExtensions, + platforms, + }); } return { diff --git a/packages/react-native-builder-bob/src/targets/typescript.ts b/packages/react-native-builder-bob/src/targets/typescript.ts index 9840eb1d4..c740629e6 100644 --- a/packages/react-native-builder-bob/src/targets/typescript.ts +++ b/packages/react-native-builder-bob/src/targets/typescript.ts @@ -2,11 +2,19 @@ import { platform } from 'node:os'; import path from 'node:path'; import { deleteAsync } from 'del'; import fs from 'fs-extra'; +import { glob } from 'glob'; import JSON5 from 'json5'; import kleur from 'kleur'; +import ts from 'typescript'; import which from 'which'; import type { Input, Variants } from '../types.ts'; +import { isCodegenSpec } from '../utils/isCodegenSpec.ts'; +import { + resolveModuleSpecifier, + SOURCE_EXTENSIONS, +} from '../utils/resolveModuleSpecifier.ts'; import { spawn } from '../utils/spawn.ts'; +import { type Replacement, updateSourceMap } from '../utils/updateSourceMap.ts'; type Options = Input & { options?: { @@ -33,6 +41,15 @@ const LOCKFILES = [ 'yarn.lock', ]; +const DECLARATION_EXTENSIONS = [{ source: 'd.ts', output: 'js' }]; + +const EXPLICIT_SOURCE_EXTENSIONS = ['ts', 'tsx'].map((source) => ({ + source, + emitted: DECLARATION_EXTENSIONS, +})); + +const DECLARATION_REWRITE_BATCH_SIZE = 32; + function isPathInside(root: string, file: string) { const relative = path.relative(root, file); const isOutside = relative === '..' || relative.startsWith(`..${path.sep}`); @@ -88,6 +105,140 @@ export async function findBinInAncestorNodeModules( } } +const getModuleSpecifier = (node: ts.Node) => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + return node.moduleSpecifier; + } + + if ( + ts.isImportTypeNode(node) && + ts.isLiteralTypeNode(node.argument) && + ts.isStringLiteral(node.argument.literal) + ) { + return node.argument.literal; + } + + return undefined; +}; + +const rewriteDeclarationImports = async ( + output: string, + source: string, + root: string, + codegenEnabled: boolean +) => { + const isCodegenImport = (filepath: string, specifier: string) => { + if (!codegenEnabled || !specifier.startsWith('.')) { + return false; + } + + // The declaration output mirrors the source tree, so map the import back to the source file + const target = path.resolve(path.dirname(filepath), specifier); + const relative = path.relative(output, target); + + // The tree root depends on tsc's inferred `rootDir`, so check both the project and source roots + return [root, source].some((base) => { + const candidate = path.join(base, relative); + + return ( + isCodegenSpec(candidate) || + SOURCE_EXTENSIONS.some((ext) => isCodegenSpec(`${candidate}.${ext}`)) + ); + }); + }; + + const files = await glob('**/*.d.ts', { + cwd: output, + absolute: true, + nodir: true, + }); + + // Process files in chunks to avoid firing read/writes for each file at once + for (let i = 0; i < files.length; i += DECLARATION_REWRITE_BATCH_SIZE) { + const promises = files + .slice(i, i + DECLARATION_REWRITE_BATCH_SIZE) + .map(async (filepath) => { + const code = await fs.readFile(filepath, 'utf-8'); + const sourceFile = ts.createSourceFile( + filepath, + code, + ts.ScriptTarget.Latest, + true + ); + + // Collect text changes before writing the file. + const replacements: Replacement[] = []; + + const addReplacement = (node: ts.StringLiteral) => { + // Rewrite the module path if it points to an emitted file. + // Only replace the text inside quotes to preserve the rest of tsc's output. + const value = resolveModuleSpecifier({ + filepath, + specifier: node.text, + extensions: DECLARATION_EXTENSIONS, + explicitExtensions: EXPLICIT_SOURCE_EXTENSIONS, + }); + + if (value !== node.text) { + replacements.push({ + start: node.getStart(sourceFile) + 1, + end: node.getEnd() - 1, + value, + }); + } + }; + + const visit = (node: ts.Node) => { + // Find module paths in this node. + // Cover static imports/exports and import() types. + const specifier = getModuleSpecifier(node); + + if (specifier && !isCodegenImport(filepath, specifier.text)) { + addReplacement(specifier); + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + + if (replacements.length) { + // Keep the source map in sync with the changed declaration file. + const sourceMapPath = `${filepath}.map`; + + if (await fs.pathExists(sourceMapPath)) { + await updateSourceMap({ + filepath: sourceMapPath, + replacements, + sourceFile, + }); + } + + // Write the declaration file with the new module paths. + await fs.writeFile( + filepath, + replacements + // Apply edits from the end so earlier offsets stay valid. + .sort((a, b) => b.start - a.start) + .reduce( + (result, replacement) => + result.slice(0, replacement.start) + + replacement.value + + result.slice(replacement.end), + code + ) + ); + } + }); + + await Promise.all(promises); + } +}; + export default async function build({ source, root, @@ -270,6 +421,12 @@ export default async function build({ // Ignore } + const pkg = JSON.parse( + await fs.readFile(path.join(root, 'package.json'), 'utf-8') + ); + + const codegenEnabled = 'codegenConfig' in pkg; + if (esm) { if (outputs?.commonjs && outputs?.module) { // When ESM compatible output is enabled and commonjs build is present, we need to generate 2 builds for commonjs and esm @@ -290,16 +447,21 @@ export default async function build({ type: 'module', }); } + + if (outputs.module) { + await rewriteDeclarationImports( + outputs.module, + source, + root, + codegenEnabled + ); + } } report.success( `Wrote definition files to ${kleur.blue(path.relative(root, output))}` ); - const pkg = JSON.parse( - await fs.readFile(path.join(root, 'package.json'), 'utf-8') - ); - const fields: Field[] = [ { name: 'types', diff --git a/packages/react-native-builder-bob/src/utils/compile.ts b/packages/react-native-builder-bob/src/utils/compile.ts index 45957236c..4714f5e3a 100644 --- a/packages/react-native-builder-bob/src/utils/compile.ts +++ b/packages/react-native-builder-bob/src/utils/compile.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import fs from 'fs-extra'; import kleur from 'kleur'; import * as babel from '@babel/core'; -import { globSync } from 'glob'; +import { glob } from 'glob'; import type { Input, Variants } from '../types.ts'; import { isCodegenSpec } from './isCodegenSpec.ts'; @@ -42,7 +42,7 @@ export default async function compile({ jsxRuntime = 'automatic', variants, }: Options) { - const files = globSync('**/*', { + const files = await glob('**/*', { cwd: source, absolute: true, nodir: true, diff --git a/packages/react-native-builder-bob/src/utils/resolveModuleSpecifier.ts b/packages/react-native-builder-bob/src/utils/resolveModuleSpecifier.ts new file mode 100644 index 000000000..65a77b092 --- /dev/null +++ b/packages/react-native-builder-bob/src/utils/resolveModuleSpecifier.ts @@ -0,0 +1,116 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const SUPPORTED_PLATFORMS = [ + 'native', + 'android', + 'ios', + 'windows', + 'macos', + 'visionos', + 'web', + 'tv', + 'android.tv', + 'ios.tv', +]; + +export const SOURCE_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx']; + +type Extension = { + source: string; + output: string; +}; + +type ExplicitExtension = { source: string } & ( + | { output: string } + | { emitted: Extension[] } +); + +type Options = { + filepath: string; + specifier: string; + extensions: Extension[]; + explicitExtensions?: ExplicitExtension[]; + platforms?: string[]; +}; + +const isFile = (filename: string): boolean => + fs.lstatSync(filename, { throwIfNoEntry: false })?.isFile() ?? false; + +const isDirectory = (filename: string): boolean => + fs.lstatSync(filename, { throwIfNoEntry: false })?.isDirectory() ?? false; + +const getModuleExtension = ( + filename: string, + extensions: Extension[], + platforms: string[] +) => { + return extensions.find( + ({ source }) => + isFile(`${filename}.${source}`) && + // Keep platform-specific imports extensionless so bundlers can still pick the right file. + platforms.every( + (platform) => !isFile(`${filename}.${platform}.${source}`) + ) + )?.output; +}; + +export const resolveModuleSpecifier = ({ + filepath, + specifier, + extensions, + explicitExtensions = [], + platforms = SUPPORTED_PLATFORMS, +}: Options) => { + if (!specifier.startsWith('.')) { + return specifier; + } + + const filename = path.resolve(path.dirname(filepath), specifier); + const explicitExtension = explicitExtensions.find(({ source }) => + filename.endsWith(`.${source}`) + ); + + if (explicitExtension) { + let output: string | undefined; + + if ('emitted' in explicitExtension) { + // An explicit extension already opts out of bundler platform resolution, + // so rewrite to the emitted file regardless of platform-specific variants + output = getModuleExtension( + filename.slice(0, -(explicitExtension.source.length + 1)), + explicitExtension.emitted, + [] + ); + } else if (isFile(filename)) { + output = explicitExtension.output; + } + + if (output) { + return specifier.slice(0, -explicitExtension.source.length) + output; + } + + return specifier; + } + + const extension = getModuleExtension(filename, extensions, platforms); + + if (extension) { + return `${specifier}.${extension}`; + } + + if (isDirectory(filename)) { + const indexExtension = getModuleExtension( + path.join(filename, 'index'), + extensions, + platforms + ); + + if (indexExtension) { + // Directory imports need to point to the emitted index file in JS and declarations + return specifier.replace(/\/?$/, `/index.${indexExtension}`); + } + } + + return specifier; +}; diff --git a/packages/react-native-builder-bob/src/utils/updateSourceMap.ts b/packages/react-native-builder-bob/src/utils/updateSourceMap.ts new file mode 100644 index 000000000..38dc535b6 --- /dev/null +++ b/packages/react-native-builder-bob/src/utils/updateSourceMap.ts @@ -0,0 +1,87 @@ +import { decode, encode } from '@jridgewell/sourcemap-codec'; +import { type } from 'arktype'; +import fs from 'fs-extra'; +import type ts from 'typescript'; + +export type Replacement = { + start: number; + end: number; + value: string; +}; + +const sourceMap = type({ + version: 'number', + file: 'string?', + sourceRoot: 'string?', + sources: 'string[]', + names: 'string[]?', + mappings: 'string', + sourcesContent: 'string[]?', +}).onDeepUndeclaredKey('ignore'); + +export const updateSourceMap = async ({ + filepath, + replacements, + sourceFile, +}: { + filepath: string; + replacements: Replacement[]; + sourceFile: ts.SourceFile; +}) => { + const map = sourceMap.assert(await fs.readJSON(filepath)); + const lines = decode(map.mappings); + + let changed = false; + + // Sort replacements in reverse order + // We apply replacements from the end, so earlier offsets stay valid + // So we won't have to keep track of changes as we go + const sortedReplacements = [...replacements].sort( + (a, b) => b.start - a.start + ); + + for (const replacement of sortedReplacements) { + // Get line and column of the replacements from the absolute character offsets + const start = sourceFile.getLineAndCharacterOfPosition(replacement.start); + const end = sourceFile.getLineAndCharacterOfPosition(replacement.end); + + // We add file extensions which only affect single lines + // So we don't handle multi-line replacements to avoid complexity + if (start.line !== end.line) { + throw new Error( + 'Source map replacement spanning multiple lines is not supported.' + ); + } + + // Get the amount of characters added or removed by the replacement + // e.g. `./foo` -> `./foo.js` adds 3 characters, so delta is 3 + const delta = + replacement.value.length - (replacement.end - replacement.start); + + if (delta === 0) { + continue; + } + + const segments = lines[start.line]; + + if (segments) { + for (const segment of segments) { + // If a mapping points to text at or after the end of the replaced import string, + // move that mapping by the same number of characters the replacement added or removed + if (segment[0] >= end.character) { + segment[0] += delta; + changed = true; + } + } + } + } + + if (!changed) { + return; + } + + await fs.writeJSON(filepath, { + ...map, + mappings: encode(lines), + }); +}; diff --git a/yarn.lock b/yarn.lock index eccb5fcba..4b5a1e703 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10331,6 +10331,7 @@ __metadata: "@babel/preset-env": "npm:^7.29.2" "@babel/preset-react": "npm:^7.28.5" "@babel/preset-typescript": "npm:^7.28.5" + "@jridgewell/sourcemap-codec": "npm:^1.5.5" "@types/babel__core": "npm:^7.20.5" "@types/browserslist": "npm:^4.15.4" "@types/cross-spawn": "npm:^6.0.6"