From 3469ece7b882c2f30a13ac70549ec4a928df94f4 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 9 Sep 2026 20:20:47 +0300 Subject: [PATCH 1/2] fix(exports): correct `./components/*` source targets for two non-tsx modules `./components/*` declares `source` and `react-native` as `./src/components/*.tsx`, but `copyComponentProperties` is authored `.ts` and `index` is `.cts`/`.ts`. Export conditions match on key presence rather than target existence, so both subpaths are a hard ERR_MODULE_NOT_FOUND under either condition, with no fallthrough to import/require. `react-native` is live: Expo sets it for iOS and Android. `source` is what react-native-builder-bob's example-app workflow runs on. An exact key beats a pattern regardless of declaration order, so two entries resolve it. `index` takes `.cts` to match what the existing exact `./components` entry already declares. Closes #445 --- package.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/package.json b/package.json index 29c04232..fc462513 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,30 @@ "types": "./dist/typescript/commonjs/src/components/index.d.ts" } }, + "./components/copyComponentProperties": { + "source": "./src/components/copyComponentProperties.ts", + "react-native": "./src/components/copyComponentProperties.ts", + "import": { + "types": "./dist/typescript/module/src/components/copyComponentProperties.d.ts", + "default": "./dist/module/components/copyComponentProperties.js" + }, + "require": { + "types": "./dist/typescript/commonjs/src/components/copyComponentProperties.d.ts", + "default": "./dist/commonjs/components/copyComponentProperties.js" + } + }, + "./components/index": { + "source": "./src/components/index.cts", + "react-native": "./src/components/index.cts", + "import": { + "types": "./dist/typescript/module/src/components/index.d.ts", + "default": "./dist/module/components/index.cjs" + }, + "require": { + "types": "./dist/typescript/commonjs/src/components/index.d.ts", + "default": "./dist/commonjs/components/index.cjs" + } + }, "./components/react-native-safe-area-context": { "source": "./src/components/react-native-safe-area-context.native.tsx", "react-native": "./src/components/react-native-safe-area-context.native.tsx", From 79eb466f38fa6553273a2009c8e7bd2243e0a9c1 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 23 Sep 2026 00:46:54 +0300 Subject: [PATCH 2/2] test(exports): assert every exports subpath resolves to a source file that exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `./components/*` wildcard maps `source` to `./src/components/*.tsx`, so a non-tsx module under that directory resolves to a file that is not there. Resolution is decidable from source alone, so this needs no build: it walks the exports map the way node does — exact key first, then the pattern whose literal prefix before `*` is longest — and checks the `source` and `react-native` targets on disk. Two censuses drive it, every literal subpath and every module in src/components/, each with a non-empty assertion. --- .../package/exports-source-targets.test.ts | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/__tests__/package/exports-source-targets.test.ts diff --git a/src/__tests__/package/exports-source-targets.test.ts b/src/__tests__/package/exports-source-targets.test.ts new file mode 100644 index 00000000..ac409d9e --- /dev/null +++ b/src/__tests__/package/exports-source-targets.test.ts @@ -0,0 +1,132 @@ +/** + * @jest-environment node + */ +import { existsSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import packageJson from "../../../package.json"; + +const PACKAGE_ROOT = resolve(__dirname, "..", "..", ".."); +const COMPONENTS_DIRECTORY = join(PACKAGE_ROOT, "src", "components"); + +type ExportsEntry = string | { readonly [condition: string]: ExportsEntry }; + +const EXPORTS_MAP = packageJson.exports as Readonly< + Record +>; + +/** Only these two conditions name a path inside `src/`; every other one names a build artifact. */ +const SOURCE_CONDITIONS = ["source", "react-native"] as const; + +const sourceTargetsOf = (entry: ExportsEntry): readonly string[] => { + if (typeof entry === "string") return []; + return SOURCE_CONDITIONS.flatMap((condition) => { + const value = entry[condition]; + return typeof value === "string" ? [value] : []; + }); +}; + +const substituteWildcard = ( + entry: ExportsEntry, + wildcard: string, +): ExportsEntry => { + if (typeof entry === "string") return entry.replace("*", wildcard); + return Object.fromEntries( + Object.entries(entry).map(([condition, value]) => [ + condition, + substituteWildcard(value, wildcard), + ]), + ); +}; + +/** Node prefers an exact key, then the pattern whose literal prefix before `*` is longest. */ +const resolveSubpath = (subpath: string): ExportsEntry | undefined => { + const exact = EXPORTS_MAP[subpath]; + if (exact !== undefined) return exact; + + let longestPrefix = -1; + let matched: ExportsEntry | undefined; + for (const [key, entry] of Object.entries(EXPORTS_MAP)) { + const star = key.indexOf("*"); + if (star === -1) continue; + const prefix = key.slice(0, star); + const suffix = key.slice(star + 1); + if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue; + if (subpath.length < prefix.length + suffix.length) continue; + if (prefix.length <= longestPrefix) continue; + longestPrefix = prefix.length; + matched = substituteWildcard( + entry, + subpath.slice(prefix.length, subpath.length - suffix.length), + ); + } + return matched; +}; + +const existenceOf = ( + subpath: string, + targets: readonly string[], +): readonly { + readonly subpath: string; + readonly target: string; + readonly exists: boolean; +}[] => + targets.map((target) => ({ + subpath, + target, + exists: existsSync(join(PACKAGE_ROOT, target)), + })); + +const allExist = ( + subpath: string, + targets: readonly string[], +): readonly { + readonly subpath: string; + readonly target: string; + readonly exists: boolean; +}[] => targets.map((target) => ({ subpath, target, exists: true })); + +const literalEntries = Object.entries(EXPORTS_MAP).filter( + ([key]) => !key.includes("*"), +); + +/** A consumer imports `./components/`, so two modules differing only by extension are one import. */ +const componentSubpaths = [ + ...new Set( + readdirSync(COMPONENTS_DIRECTORY).map( + (file) => `./components/${file.replace(/\.[^.]+$/u, "")}`, + ), + ), +].sort(); + +describe("exports source targets", () => { + it("has subpaths and component modules to check", () => { + expect(literalEntries.length).toBeGreaterThan(0); + expect(componentSubpaths.length).toBeGreaterThan(0); + }); + + it.each(literalEntries)( + "%s names source files that exist", + (subpath, entry) => { + const targets = sourceTargetsOf(entry); + expect(existenceOf(subpath, targets)).toStrictEqual( + allExist(subpath, targets), + ); + }, + ); + + it.each(componentSubpaths)( + "%s resolves to a source file that exists", + (subpath) => { + const entry = resolveSubpath(subpath); + if (entry === undefined) + throw new Error(`${subpath} resolves through no exports entry`); + + const targets = sourceTargetsOf(entry); + expect(targets.length).toBeGreaterThan(0); + expect(existenceOf(subpath, targets)).toStrictEqual( + allExist(subpath, targets), + ); + }, + ); +});