diff --git a/.ado/jobs/npm-publish.yml b/.ado/jobs/npm-publish.yml index 0fa3d7196c68..5370dfee8f17 100644 --- a/.ado/jobs/npm-publish.yml +++ b/.ado/jobs/npm-publish.yml @@ -1,6 +1,8 @@ jobs: - job: NPMPublish displayName: NPM Publish + # Also disable direct template consumers until ADO publication is re-enabled. + condition: false pool: name: cxeiss-ubuntu-20-04-large image: cxe-ubuntu-20-04-1es-pt diff --git a/.ado/publish.yml b/.ado/publish.yml index 090cd21cd91e..2a0dab4f252a 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -51,5 +51,8 @@ extends: stages: - stage: NPM dependsOn: [] + # GitHub Trusted Publishing owns releases. Retain the ADO publication + # path for explicit re-enablement after its contract/auth are reviewed. + condition: false jobs: - template: /.ado/jobs/npm-publish.yml@self diff --git a/.ado/scripts/configure-publish.mts b/.ado/scripts/configure-publish.mts index 31b336441f56..256168447af3 100644 --- a/.ado/scripts/configure-publish.mts +++ b/.ado/scripts/configure-publish.mts @@ -1,237 +1,45 @@ #!/usr/bin/env node -import { $, argv, echo, fs } from 'zx'; -import { resolve } from 'node:path'; - -const isGitHubActions = process.env['GITHUB_ACTIONS'] === 'true'; - -const NPM_TAG_NEXT = 'next'; - -export type ReleaseState = 'STABLE_IS_LATEST' | 'STABLE_IS_NEW' | 'STABLE_IS_OLD'; - -export interface ReleaseStateInfo { - state: ReleaseState; - currentVersion: number; - latestVersion: number; - nextVersion: number; -} - -export interface TagInfo { - npmTags: string[]; - prerelease?: string; -} - -interface Options { - 'mock-branch'?: string; - tag?: string; - verbose?: boolean; -} - -function enablePublishingOnAzurePipelines() { - echo(`##vso[task.setvariable variable=publish_react_native_macos]1`); -} - -function enablePublishingOnGitHubActions() { - if (process.env['GITHUB_OUTPUT']) { - fs.appendFileSync(process.env['GITHUB_OUTPUT'], `publish_react_native_macos=1\n`); - } -} - -export function isMainBranch(branch: string): boolean { - return branch === 'main'; -} - -export function isStableBranch(branch: string): boolean { - return /^\d+\.\d+-stable$/.test(branch); -} - -export function versionToNumber(version: string): number { - const [major, minor] = version.split('-')[0].split('.'); - return Number(major) * 1000 + Number(minor); -} - -function getTargetBranch(): string | undefined { - // Azure Pipelines - if (process.env['TF_BUILD'] === 'True') { - const targetBranch = process.env['SYSTEM_PULLREQUEST_TARGETBRANCH']; - return targetBranch?.replace(/^refs\/heads\//, ''); - } - - // GitHub Actions - if (process.env['GITHUB_ACTIONS'] === 'true') { - return process.env['GITHUB_BASE_REF']; - } - - return undefined; -} - -async function getCurrentBranch(options: Options): Promise { - const targetBranch = getTargetBranch(); - if (targetBranch) { - return targetBranch; - } - - // Azure DevOps Pipelines - if (process.env['TF_BUILD'] === 'True') { - const sourceBranch = process.env['BUILD_SOURCEBRANCHNAME']; - if (sourceBranch) { - return sourceBranch.replace(/^refs\/heads\//, ''); - } - } - - // GitHub Actions - if (process.env['GITHUB_ACTIONS'] === 'true') { - const headRef = process.env['GITHUB_HEAD_REF']; - if (headRef) return headRef; - - const ref = process.env['GITHUB_REF']; - if (ref) return ref.replace(/^refs\/heads\//, ''); - } - - if (options['mock-branch']) { - return options['mock-branch']; - } - - const result = await $`git rev-parse --abbrev-ref HEAD`; - return result.stdout.trim(); -} - -function getPublishedVersionSync(tag: 'latest' | 'next'): number { - const result = $.sync`npm view react-native-macos@${tag} version`; - return versionToNumber(result.stdout.trim()); -} - -export function getReleaseState( - branch: string, - getVersion: (tag: 'latest' | 'next') => number = getPublishedVersionSync, -): ReleaseStateInfo { - if (!isStableBranch(branch)) { - throw new Error('Expected a stable branch'); - } - - const latestVersion = getVersion('latest'); - const nextVersion = getVersion('next'); - const currentVersion = versionToNumber(branch); - - let state: ReleaseState; - if (currentVersion === latestVersion) { - state = 'STABLE_IS_LATEST'; - } else if (currentVersion < latestVersion) { - state = 'STABLE_IS_OLD'; +import {execFileSync} from 'node:child_process'; +import {appendFileSync} from 'node:fs'; +import {parseArgs} from 'node:util'; +import { + createPublishPlan, + isStableBranch, + publishPrepared, + readChangesetStatus, + readWorkspaces, +} from '../../.github/scripts/publishing-contract.mjs'; + +const {values: options} = parseArgs({options: { + 'mock-branch': {type: 'string'}, + verbose: {type: 'boolean'}, + publish: {type: 'boolean'}, +}}); +const branch = process.env.GITHUB_REF_NAME ?? process.env.BUILD_SOURCEBRANCHNAME ?? + options['mock-branch'] ?? execFileSync('git', ['branch', '--show-current'], {encoding: 'utf8'}).trim(); +const isPullRequest = Boolean(process.env.GITHUB_BASE_REF || + process.env.SYSTEM_PULLREQUEST_TARGETBRANCH || process.env.BUILD_REASON === 'PullRequest'); + +function output(name: string, value: string) { + if (process.env.TF_BUILD === 'True') console.log(`##vso[task.setvariable variable=${name}]${value}`); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); +} + +output('publish_react_native_macos', '0'); +if (!isStableBranch(branch) || isPullRequest) { + console.log(`Publication disabled for ${isPullRequest ? 'pull requests' : branch}`); +} else { + const plan = await createPublishPlan({ + branch, + status: await readChangesetStatus(), + workspaces: readWorkspaces(), + }); + if (options.verbose) console.log(JSON.stringify(plan, null, 2)); + output('publishTag', plan.tag ?? ''); + if (plan.packages.length) { + output('publish_react_native_macos', '1'); + if (options.publish) publishPrepared(plan); } else { - state = 'STABLE_IS_NEW'; - } - - return { state, currentVersion, latestVersion, nextVersion }; -} - -export function getPublishTags( - stateInfo: ReleaseStateInfo, - branch: string, - tag: string = NPM_TAG_NEXT, -): TagInfo { - const { state, currentVersion, nextVersion } = stateInfo; - - switch (state) { - case 'STABLE_IS_LATEST': - // Patching the current latest version - return { npmTags: ['latest', branch] }; - - case 'STABLE_IS_OLD': - // Patching an older stable version - return { npmTags: [branch] }; - - case 'STABLE_IS_NEW': { - if (tag === 'latest') { - // Promoting this branch to latest - const npmTags = ['latest', branch]; - if (currentVersion > nextVersion) { - npmTags.push(NPM_TAG_NEXT); - } - return { npmTags }; - } - - // Publishing a release candidate - if (currentVersion < nextVersion) { - throw new Error( - `Current version cannot be a release candidate because it is too old: ${currentVersion} < ${nextVersion}`, - ); - } - - return { npmTags: [NPM_TAG_NEXT], prerelease: 'rc' }; - } - } -} - -async function enablePublishing(tagInfo: TagInfo, options: Options) { - const [primaryTag, ...additionalTags] = tagInfo.npmTags; - - // Output publishTag for subsequent pipeline steps - echo(`##vso[task.setvariable variable=publishTag]${primaryTag}`); - if (process.env['GITHUB_OUTPUT']) { - fs.appendFileSync(process.env['GITHUB_OUTPUT'], `publishTag=${primaryTag}\n`); - } - - // Output additional tags - if (additionalTags.length > 0) { - const tagsValue = additionalTags.join(','); - echo(`##vso[task.setvariable variable=additionalTags]${tagsValue}`); - if (process.env['GITHUB_OUTPUT']) { - fs.appendFileSync(process.env['GITHUB_OUTPUT'], `additionalTags=${tagsValue}\n`); - } - } - - // Don't enable publishing in PRs - if (!getTargetBranch()) { - if (isGitHubActions) { - enablePublishingOnGitHubActions(); - } else if (process.env['TF_BUILD'] === 'True') { - enablePublishingOnAzurePipelines(); - } else { - echo('ℹ️ Local run — publishing not enabled'); - } - } -} - -const isDirectRun = - process.argv[1] != null && - resolve(process.argv[1]) === new URL(import.meta.url).pathname; - -if (isDirectRun) { - // Parse CLI args using zx's argv (minimist) - const options: Options = { - 'mock-branch': argv['mock-branch'] as string | undefined, - tag: typeof argv['tag'] === 'string' ? argv['tag'] : NPM_TAG_NEXT, - verbose: Boolean(argv['verbose']), - }; - - const branch = await getCurrentBranch(options); - if (!branch) { - echo('❌ Could not get current branch'); - process.exit(1); - } - - const log = options.verbose ? (msg: string) => echo(`ℹ️ ${msg}`) : () => {}; - - try { - if (isMainBranch(branch)) { - // Nightlies are currently disabled — skip publishing from main - echo('ℹ️ On main branch — nightly publishing is currently disabled'); - } else if (isStableBranch(branch)) { - const stateInfo = getReleaseState(branch); - log(`react-native-macos@latest: ${stateInfo.latestVersion}`); - log(`react-native-macos@next: ${stateInfo.nextVersion}`); - log(`Current version: ${stateInfo.currentVersion}`); - log(`Release state: ${stateInfo.state}`); - - const tagInfo = getPublishTags(stateInfo, branch, options.tag); - log(`Expected npm tags: ${tagInfo.npmTags.join(', ')}`); - - await enablePublishing(tagInfo, options); - } else { - echo(`ℹ️ Branch '${branch}' is not main or a stable branch — skipping`); - } - } catch (e) { - echo(`❌ ${(e as Error).message}`); - process.exit(1); + console.log(plan.reason ?? 'All prepared versions are already published'); } } diff --git a/.changeset/config.json b/.changeset/config.json index 524d843b7dfa..d94a90f458c1 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,8 +2,14 @@ "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "access": "public", "baseBranch": "origin/main", + "bumpVersionsWithWorkspaceProtocolOnly": true, "changelog": "@changesets/cli/changelog", "commit": false, - "ignore": [], + "fixed": [["react-native-macos", "@react-native-macos/virtualized-lists"]], + "ignore": ["react-native-macos", "@react-native/tester"], + "privatePackages": { + "version": false, + "tag": false + }, "linked": [] } diff --git a/.github/actions/setup-xcode/action.yml b/.github/actions/setup-xcode/action.yml index 79ce3d47aa79..801c4750085a 100644 --- a/.github/actions/setup-xcode/action.yml +++ b/.github/actions/setup-xcode/action.yml @@ -4,7 +4,7 @@ inputs: xcode-version: description: 'The xcode version to use' required: false - default: '26.2' + default: '16.2.0' platform: description: 'The platform to use. Valid values are: ios, ios-simulator, macos, mac-catalyst, tvos, tvos-simulator, xros, xros-simulator' required: false diff --git a/.github/scripts/__tests__/__fixtures__/resolve-hermes.cjs b/.github/scripts/__tests__/__fixtures__/resolve-hermes.cjs new file mode 100644 index 000000000000..7bc7fd14b138 --- /dev/null +++ b/.github/scripts/__tests__/__fixtures__/resolve-hermes.cjs @@ -0,0 +1,55 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +// Preload in the CLI subprocess. Exercise the real ESM entry point and URL +// helper without network access or changes to checked-in metadata. +const fs = require('node:fs'); +const path = require('node:path'); + +const propertiesPath = path.resolve( + __dirname, + '../../../../packages/react-native/sdks/hermes-engine/version.properties', +); +const readFileSync = fs.readFileSync; +fs.readFileSync = function (file, ...args) { + if (file === propertiesPath && process.env.HERMES_TEST_PROPERTIES != null) { + if (process.env.HERMES_TEST_PROPERTIES === 'MISSING') { + throw Object.assign(new Error(`ENOENT: ${propertiesPath}`), { + code: 'ENOENT', + }); + } + if (process.env.HERMES_TEST_PROPERTIES === 'UNREADABLE') { + throw Object.assign(new Error(`EACCES: ${propertiesPath}`), { + code: 'EACCES', + }); + } + return process.env.HERMES_TEST_PROPERTIES; + } + return readFileSync.call(this, file, ...args); +}; + +const urls = []; +global.fetch = async url => { + urls.push(url); + const mode = process.env.HERMES_TEST_DOWNLOAD; + if (url.endsWith('/maven-metadata.xml')) { + return { + ok: mode === 'snapshot', + text: async () => + '20260101.0102034', + }; + } + return { + ok: mode === 'release' || (mode === 'snapshot' && url.includes('SNAPSHOT')), + status: 404, + statusText: 'Not Found', + arrayBuffer: async () => Buffer.from('mock Hermes archive'), + }; +}; +process.on('exit', () => console.log(`HERMES_TEST_URLS=${JSON.stringify(urls)}`)); diff --git a/.github/scripts/__tests__/change.test.mjs b/.github/scripts/__tests__/change.test.mjs new file mode 100644 index 000000000000..1d49f666df5a --- /dev/null +++ b/.github/scripts/__tests__/change.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {test} from 'node:test'; +import {getBaseBranch} from '../change.mts'; + +const repository = {url: 'git+https://github.com/microsoft/react-native-macos.git'}; + +function fixture(t, {baseBranch = 'origin/main', rootRepository, coreRepository = repository, + remotes = {origin: 'https://github.com/contributor/react-native-macos.git', + upstream: 'git@github.com:microsoft/react-native-macos.git'}} = {}) { + const root = mkdtempSync(join(tmpdir(), 'rnm-change-base-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + const baseRef = process.env.GITHUB_BASE_REF; + t.after(() => { + if (baseRef === undefined) delete process.env.GITHUB_BASE_REF; + else process.env.GITHUB_BASE_REF = baseRef; + }); + delete process.env.GITHUB_BASE_REF; + mkdirSync(join(root, '.changeset')); + mkdirSync(join(root, 'packages/react-native'), {recursive: true}); + writeFileSync(join(root, 'package.json'), JSON.stringify({repository: rootRepository})); + writeFileSync(join(root, 'packages/react-native/package.json'), JSON.stringify({repository: coreRepository})); + writeFileSync(join(root, '.changeset/config.json'), JSON.stringify({baseBranch})); + const git = args => execFileSync('git', args, {cwd: root, encoding: 'utf8'}); + git(['init', '-q']); + for (const [name, url] of Object.entries(remotes)) git(['remote', 'add', name, url]); + return root; +} + +for (const branch of ['main', '0.83-stable', 'release/0.83']) { + test(`local ${branch} uses core metadata to remap a fork origin to upstream`, async t => { + const root = fixture(t, {baseBranch: `origin/${branch}`}); + assert.equal(await getBaseBranch(root), `upstream/${branch}`); + }); +} + +test('root repository metadata takes precedence and remote matching is exact', async t => { + const root = fixture(t, {rootRepository: {url: 'https://github.com/example/project.git'}, + remotes: {origin: 'https://github.com/example/project-extra.git', + canonical: 'git@github.com:Example/Project.git', upstream: repository.url}}); + assert.equal(await getBaseBranch(root), 'canonical/main'); +}); + +test('a normal Microsoft origin honors main and the configured stable branch', async t => { + const root = fixture(t, {remotes: {origin: repository.url}}); + assert.equal(await getBaseBranch(root), 'origin/main'); + writeFileSync(join(root, '.changeset/config.json'), JSON.stringify({baseBranch: 'origin/0.83-stable'})); + assert.equal(await getBaseBranch(root), 'origin/0.83-stable'); +}); + +test('an empty root URL falls back to string core metadata and an arbitrary remote name', async t => { + const root = fixture(t, {rootRepository: '', coreRepository: repository.url, + baseBranch: 'origin/0.83-stable', remotes: {canonical: repository.url}}); + assert.equal(await getBaseBranch(root), 'canonical/0.83-stable'); +}); + +test('an explicit configured remote or local branch remains authoritative', async t => { + const root = fixture(t, {remotes: {origin: repository.url, review: 'https://github.com/contributor/react-native-macos.git'}}); + for (const baseBranch of ['review/0.83-stable', 'main', 'release/0.83']) { + writeFileSync(join(root, '.changeset/config.json'), JSON.stringify({baseBranch})); + assert.equal(await getBaseBranch(root), baseBranch); + } +}); + +test('GITHUB_BASE_REF takes precedence over local config for forks and CI checkouts', async t => { + const root = fixture(t, {baseBranch: 'review/main'}); + process.env.GITHUB_BASE_REF = '0.83-stable'; + assert.equal(await getBaseBranch(root), 'upstream/0.83-stable'); + execFileSync('git', ['remote', 'remove', 'upstream'], {cwd: root}); + execFileSync('git', ['remote', 'set-url', 'origin', repository.url], {cwd: root}); + assert.equal(await getBaseBranch(root), 'origin/0.83-stable'); +}); + +test('missing repository metadata retains the origin fallback', async t => { + const root = fixture(t, {baseBranch: 'origin/0.83-stable', coreRepository: {}}); + assert.equal(await getBaseBranch(root), 'origin/0.83-stable'); + rmSync(join(root, 'packages/react-native/package.json')); + assert.equal(await getBaseBranch(root), 'origin/0.83-stable'); +}); + +test('manifest, config, and Git errors propagate from the public resolver', async t => { + const root = fixture(t); + const config = join(root, '.changeset/config.json'); + writeFileSync(config, '{'); + await assert.rejects(getBaseBranch(root), SyntaxError); + writeFileSync(config, JSON.stringify({baseBranch: 'origin/main'})); + writeFileSync(join(root, 'packages/react-native/package.json'), '{'); + await assert.rejects(getBaseBranch(root), SyntaxError); + writeFileSync(join(root, 'packages/react-native/package.json'), JSON.stringify({repository})); + rmSync(join(root, '.git'), {recursive: true}); + await assert.rejects(getBaseBranch(root), /not a git repository/); +}); diff --git a/.github/scripts/__tests__/check-release-published.test.mjs b/.github/scripts/__tests__/check-release-published.test.mjs new file mode 100644 index 000000000000..4a7708269d3f --- /dev/null +++ b/.github/scripts/__tests__/check-release-published.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {isReleasePublished} from '../check-release-published.mjs'; + +test('a successful registry response distinguishes released and new minors', () => { + const query = () => JSON.stringify(['0.81.9', '0.83.0-rc.0', '0.84.0']); + assert.equal(isReleasePublished('0.83', query), true); + assert.equal(isReleasePublished('0.85', query), false); + assert.equal(isReleasePublished('0.8', query), false); + assert.equal(isReleasePublished('0.83', () => '[]'), false); +}); + +test('registry failures propagate instead of skipping integration', () => { + const error = new Error('Registry unavailable'); + assert.throws(() => isReleasePublished('0.83', () => { throw error; }), error); +}); + +test('invalid registry data and invalid minor versions fail', () => { + for (const response of ['not JSON', '{}', 'null', '[null]']) { + assert.throws(() => isReleasePublished('0.83', () => response)); + } + assert.throws(() => isReleasePublished('undefined', () => '[]')); +}); diff --git a/.github/scripts/__tests__/export-versions.test.mjs b/.github/scripts/__tests__/export-versions.test.mjs new file mode 100644 index 000000000000..df91b3b91594 --- /dev/null +++ b/.github/scripts/__tests__/export-versions.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {test} from 'node:test'; + +const script = new URL('../export-versions.mts', import.meta.url); + +for (const {name, peerVersion, codegenVersion, expected} of [ + {name: 'main uses the codegen workspace version', codegenVersion: '0.83.0-main', expected: '0.83'}, + {name: 'stable prefers its explicit React Native peer', peerVersion: '0.83.10', codegenVersion: '0.83.0-main', expected: '0.83'}, + {name: 'the peer takes precedence over a different workspace minor', peerVersion: '0.84.1', codegenVersion: '0.83.0-main', expected: '0.84'}, + {name: 'later fork points use their own workspace version', codegenVersion: '0.87.0-main', expected: '0.87'}, +]) { + test(name, t => { + const root = mkdtempSync(join(tmpdir(), 'rnm-export-versions-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + mkdirSync(join(root, '.github/scripts'), {recursive: true}); + mkdirSync(join(root, 'packages/react-native'), {recursive: true}); + mkdirSync(join(root, 'packages/react-native-codegen'), {recursive: true}); + const target = join(root, '.github/scripts/export-versions.mts'); + copyFileSync(script, target); + writeFileSync(join(root, 'packages/react-native/package.json'), JSON.stringify({ + dependencies: {'@react-native/codegen': 'workspace:*'}, + peerDependencies: {react: '^19.2.0', ...(peerVersion ? {'react-native': peerVersion} : {})}, + })); + writeFileSync(join(root, 'packages/react-native-codegen/package.json'), JSON.stringify({version: codegenVersion})); + const output = join(root, 'github-output'); + const env = {...process.env, GITHUB_OUTPUT: output}; + execFileSync(process.execPath, [target], {env}); + assert.equal(readFileSync(output, 'utf8'), `react_version=^19.2.0\nreact_native_version=${expected}\n`); + delete env.GITHUB_OUTPUT; + assert.equal(execFileSync(process.execPath, [target], {env, encoding: 'utf8'}), + `react_version=^19.2.0\nreact_native_version=${expected}\n`); + }); +} diff --git a/.github/scripts/__tests__/publishing-contract.test.mjs b/.github/scripts/__tests__/publishing-contract.test.mjs new file mode 100644 index 000000000000..03bac88df959 --- /dev/null +++ b/.github/scripts/__tests__/publishing-contract.test.mjs @@ -0,0 +1,968 @@ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {test} from 'node:test'; +import { + createPublishPlan, + parseVersion, + publishedMetadata, + publishPrepared, + publishTag, + readChangesetStatus, + readWorkspaces, + validateChangesetConfig, + validateRelease, + validatePreparedVersionPR, + canAdvanceTag, +} from '../publishing-contract.mjs'; +import {releaseAlignmentChangeset, versionWithPostbump, withReleaseConfig} from '../changeset-version-with-postbump.mts'; +import {isCurrentHead} from '../check-version-head.mjs'; +import {runCheck} from '../change.mts'; + +const core = 'react-native-macos'; +const lists = '@react-native-macos/virtualized-lists'; +const branch = '0.83-stable'; +const clean = {changesets: [], releases: []}; +const require = createRequire(import.meta.url); +const metadata = (versions = [], tags = {}) => ({versions, tags}); + +function graph(version = '0.83.2') { + return [ + {name: core, version, dependencies: {[lists]: 'workspace:*', '@react-native/codegen': '0.83.1'}}, + {name: lists, version}, + {name: '@react-native/codegen', version: '0.83.1', private: true}, + {name: '@react-native-macos/internal', version: '1000.0.0', private: true}, + {name: 'react-native-macos-init', version: '2.1.3'}, + {name: '@react-native/unrelated', version: '0.83.0'}, + ]; +} + +function plan(overrides = {}) { + return createPublishPlan({workspaces: graph(), branch, status: clean, + getMetadata: async () => metadata(), ...overrides}); +} + +test('pending normal, empty, and release-only Changesets skip registry and publication', async () => { + for (const status of [ + {changesets: [{id: 'fix', releases: [{name: core, type: 'patch'}]}], releases: []}, + {changesets: [{id: 'empty', releases: []}], releases: []}, + {changesets: [], releases: [{name: core, newVersion: '0.83.3'}]}, + ]) { + const result = await plan({status, workspaces: graph('1000.0.0'), + getMetadata: () => assert.fail('Registry queried with pending Changesets')}); + publishPrepared(result, () => assert.fail('Published with pending Changesets')); + assert.deepEqual(result.packages, []); + } +}); + +test('only unpublished coupled packages publish, in runtime dependency order', async () => { + const queried = []; + const result = await plan({getMetadata: async name => { + queried.push(name); + return metadata(['0.83.1']); + }}); + assert.deepEqual(queried, [core, lists]); + assert.deepEqual(result.packages, [{name: lists, version: '0.83.2', tag: 'latest'}, {name: core, version: '0.83.2', tag: 'latest'}]); + const calls = []; + publishPrepared(result, (...args) => calls.push(args)); + assert.deepEqual(calls.map(([command, args]) => [command, args]), [lists, core].map(name => [ + 'yarn', ['workspace', name, 'npm', 'publish', '--provenance', '--tag', 'latest', '--tolerate-republish'], + ])); +}); + +test('partial publication retries only the missing package with one publish-time tag', async () => { + const result = await plan({getMetadata: async name => metadata(name === lists ? ['0.83.2'] : [])}); + assert.deepEqual(result.packages, [{name: core, version: '0.83.2', tag: 'latest'}]); + const calls = []; + publishPrepared(result, (command, args) => calls.push([command, args])); + assert.deepEqual(calls, [['yarn', ['workspace', core, 'npm', 'publish', '--provenance', '--tag', 'latest', '--tolerate-republish']]]); + const complete = await plan({getMetadata: async () => metadata(['0.83.2'], {latest: '0.83.2'})}); + publishPrepared(complete, () => assert.fail('Republished an existing version')); + assert.deepEqual(complete, {packages: [], tag: 'latest'}); +}); + +test('registry failures prevent all publication, including a failure on the last package', async () => { + await assert.rejects(plan({getMetadata: async name => { + if (name === lists) throw new Error('Registry unavailable'); + return metadata(); + }}), /Registry unavailable/); +}); + +test('invalid Changesets status fails before registry access', async () => { + await assert.rejects(plan({status: {}, + getMetadata: () => assert.fail('Queried registry without Changesets status')}), /Invalid Changesets status/); +}); + +test('tag selection uses actual versions and published stable lines, not next or latest aliases', () => { + assert.equal(publishTag('0.83.0', branch, ['0.82.9', '0.84.0-rc.1']), 'latest'); + assert.equal(publishTag('0.83.2', branch, ['0.83.1']), 'latest'); + assert.equal(publishTag('0.83.2', branch, ['0.84.0']), branch); + assert.equal(publishTag('0.83.2-rc.1', branch, ['0.84.0']), 'next'); + assert.equal(publishTag('0.83.2+build.1', branch, []), 'latest'); + assert.equal(publishTag('1.0.0', '1.0-stable', ['0.999.0']), 'latest'); +}); + +test('placeholder, malformed, branch-mismatched, and package-mismatched versions fail before registry access', async () => { + for (const version of ['1000.0.0', '1000.0.0-rc.1', '0.83', '0.83.01', '0.83.1-01', '0.84.0']) { + await assert.rejects(plan({workspaces: graph(version), + getMetadata: () => assert.fail('Queried invalid release')}), /version|match/i); + } + const workspaces = graph(); + workspaces[1].version = '0.83.1'; + assert.throws(() => validateRelease(workspaces, branch), /does not match/); + assert.throws(() => validateRelease(graph(), 'main'), /stable branch/); + assert.throws(() => validateRelease(graph().slice(1), branch), /Missing public/); + assert.throws(() => parseVersion('garbage'), /Invalid release/); +}); + +test('runtime private/local dependencies fail; explicit upstream registry ranges and private dev dependencies pass', () => { + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + for (const range of ['workspace:*', 'workspace:^', 'workspace:0.83.1', 'file:../codegen', 'link:../codegen', '1000.0.0']) { + const workspaces = graph(); + workspaces[0][field] = {'@react-native/codegen': range}; + assert.throws(() => validateRelease(workspaces, branch), /runtime|unreleasable/); + } + } + const workspaces = graph(); + workspaces[0].devDependencies = {'@react-native/codegen': 'workspace:*'}; + assert.equal(validateRelease(workspaces, branch).length, 2); + workspaces[0].optionalDependencies = {'@react-native-macos/internal': '0.83.2'}; + assert.throws(() => validateRelease(workspaces, branch), /private runtime dependency/); + delete workspaces[0].optionalDependencies; + workspaces[0].dependencies[lists] = '0.83.1'; + assert.throws(() => validateRelease(workspaces, branch), /mismatched runtime dependency/); +}); + +test('runtime graph cycles fail before publish', async () => { + const workspaces = graph(); + workspaces[1].dependencies = {[core]: 'workspace:*'}; + await assert.rejects(plan({workspaces}), /cycle/); +}); + +test('registry adapter distinguishes missing packages from auth, network, and malformed responses', async () => { + const result = await publishedMetadata(lists, async url => { + assert.equal(url, 'https://registry.npmjs.org/%40react-native-macos%2Fvirtualized-lists'); + return Response.json({versions: {'0.83.1': {}}, 'dist-tags': {latest: '0.82.0', next: '0.84.0-rc.1'}}); + }); + assert.deepEqual(result, metadata(['0.83.1'], {latest: '0.82.0', next: '0.84.0-rc.1'})); + assert.deepEqual(await publishedMetadata(core, async () => new Response(null, {status: 404})), metadata()); + for (const status of [401, 403, 429, 500]) { + await assert.rejects(publishedMetadata(core, async () => new Response(null, {status})), /Registry query failed/); + } + for (const metadata of [{}, {versions: []}, {versions: 'invalid'}]) { + await assert.rejects(publishedMetadata(core, async () => Response.json(metadata)), /Invalid registry metadata/); + } + await assert.rejects(publishedMetadata(core, async () => {throw new Error('offline');}), /offline/); +}); + +function releaseFixture(t, {versionPrivatePackages = false, workspaces = graph(), config: policy} = {}) { + const root = mkdtempSync(join(tmpdir(), 'rnm-release-api-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + mkdirSync(join(root, '.changeset')); + writeFileSync(join(root, 'package.json'), JSON.stringify({name: 'release-fixture', private: true, workspaces: ['packages/*']})); + // Keep the synthetic graph independent of branch-specific release configuration. + const config = policy ?? { + access: 'public', baseBranch: 'origin/nonexistent', + changelog: require.resolve('@changesets/cli/changelog'), commit: false, + fixed: [], linked: [], ignore: [], + bumpVersionsWithWorkspaceProtocolOnly: true, + privatePackages: {version: versionPrivatePackages, tag: false}, + }; + writeFileSync(join(root, '.changeset/config.json'), JSON.stringify(config)); + for (const [index, pkg] of workspaces.entries()) { + mkdirSync(join(root, `packages/p${index}`), {recursive: true}); + writeFileSync(join(root, `packages/p${index}/package.json`), JSON.stringify(pkg)); + } + return {root, workspaces}; +} + +const repositoryRoot = new URL('../../../', import.meta.url).pathname; +const releasePolicy = JSON.parse(readFileSync(join(repositoryRoot, '.changeset/config.json'), 'utf8')); +const getReleasePlan = require('@changesets/get-release-plan').default; +const semver = require('semver'); +// Stable preparation clears main's deferral of the unreleasable development graph. +const stablePolicy = {...releasePolicy, baseBranch: `origin/${branch}`, ignore: []}; +// Older Changesets also requires private dependents of ignored packages here. +const mainPolicy = {...releasePolicy, baseBranch: 'origin/main', ignore: [core, '@react-native/tester']}; + +test('repository Changesets policy disables private versions and tags and fixes core with lists', () => { + assert.deepEqual(releasePolicy.privatePackages, {version: false, tag: false}); + assert.deepEqual(releasePolicy.fixed, [[core, lists]]); +}); + +test('dedicated Changesets config validation follows the package graph and CI base', () => { + const stableWorkspaces = graph(); + assert.deepEqual(validateChangesetConfig({ + root: repositoryRoot, + workspaces: stableWorkspaces, + config: stablePolicy, + baseRef: branch, + }), {baseBranch: `origin/${branch}`, mode: 'stable'}); + + const mainWorkspaces = graph('1000.0.0'); + mainWorkspaces.find(pkg => pkg.name === lists).private = true; + assert.deepEqual(validateChangesetConfig({ + root: repositoryRoot, + workspaces: mainWorkspaces, + config: mainPolicy, + baseRef: 'main', + }), {baseBranch: 'origin/main', mode: 'development'}); + + for (const config of [ + {...stablePolicy, baseBranch: 'origin/main'}, + {...stablePolicy, ignore: [core]}, + {...stablePolicy, fixed: []}, + {...stablePolicy, privatePackages: {version: true, tag: false}}, + ]) { + assert.throws(() => validateChangesetConfig({ + root: repositoryRoot, + workspaces: stableWorkspaces, + config, + baseRef: branch, + })); + } +}); + +test('repository Changesets policy follows the actual public and private workspace graph', async t => { + const workspaces = readWorkspaces(repositoryRoot); + const corePackage = workspaces.find(pkg => pkg.name === core); + const listsPackage = workspaces.find(pkg => pkg.name === lists); + assert.ok(corePackage && !corePackage.private, 'Missing public core workspace'); + assert.ok(listsPackage, 'Missing lists workspace'); + const main = corePackage.version === '1000.0.0'; + assert.deepEqual(releasePolicy.ignore, main ? mainPolicy.ignore : []); + assert.equal(releasePolicy.baseBranch, main ? 'origin/main' + : `origin/${semver.major(corePackage.version)}.${semver.minor(corePackage.version)}-stable`); + assert.equal(Boolean(listsPackage.private), main, 'Lists must be public on stable and private on main'); + assert.equal(listsPackage.version, corePackage.version); + const publicPackages = [corePackage, listsPackage]; + // Derive expectations from manifests, never from the policy or release-plan output. + const nextVersion = semver.inc(publicPackages.map(pkg => pkg.version).sort(semver.rcompare)[0], 'patch'); + const expected = main ? [] : publicPackages.map(pkg => [pkg.name, nextVersion]).sort(); + const {root} = releaseFixture(t, {workspaces, config: releasePolicy}); + assert.deepEqual((await getReleasePlan(root)).releases, []); + for (const changed of [core, lists]) { + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${changed}": patch\n---\n\nFix package.\n`); + const bumped = (await getReleasePlan(root)).releases.filter(pkg => pkg.type !== 'none'); + assert.deepEqual(bumped.map(pkg => [pkg.name, pkg.newVersion]).sort(), expected); + } +}); + +test('main defers the private runtime graph, preserves pending core changes, and releases them after stable preparation', async t => { + const workspaces = graph('1000.0.0'); + workspaces[1].private = true; + workspaces[0].dependencies['@react-native/codegen'] = 'workspace:*'; + workspaces.push({name: '@react-native/tester', version: '1000.0.0', private: true, + devDependencies: {[core]: 'workspace:*'}}); + const {root} = releaseFixture(t, {workspaces, config: mainPolicy}); + const coreChange = '---\n"react-native-macos": patch\n---\n\nFix core.\n'; + writeFileSync(join(root, '.changeset/core.md'), coreChange); + writeFileSync(join(root, '.changeset/init.md'), '---\n"react-native-macos-init": patch\n---\n\nFix init.\n'); + assert.deepEqual((await getReleasePlan(root)).releases.map(pkg => [pkg.name, pkg.newVersion]), + [['react-native-macos-init', '2.1.4']]); + const version = () => execFileSync(process.execPath, [require.resolve('@changesets/cli/bin.js'), 'version'], { + cwd: root, encoding: 'utf8', env: {...process.env, CI: 'true'}, + }); + version(); + for (const [index, pkg] of workspaces.entries()) { + const expected = pkg.name === 'react-native-macos-init' ? {...pkg, version: '2.1.4'} : pkg; + assert.deepEqual(JSON.parse(readFileSync(join(root, `packages/p${index}/package.json`), 'utf8')), expected); + } + assert.equal(readFileSync(join(root, '.changeset/core.md'), 'utf8'), coreChange); + assert.deepEqual((await getReleasePlan(root)).releases, []); + + // Model the committed stable preparation: public coupled versions and registry + // inputs for upstream packages, with no ignored public release packages. + workspaces[0].version = workspaces[1].version = '0.83.0'; + workspaces[0].dependencies['@react-native/codegen'] = '0.83.1'; + delete workspaces[1].private; + for (const index of [0, 1]) { + writeFileSync(join(root, `packages/p${index}/package.json`), JSON.stringify(workspaces[index])); + } + writeFileSync(join(root, '.changeset/config.json'), JSON.stringify(stablePolicy)); + assert.deepEqual((await getReleasePlan(root)).releases.filter(pkg => pkg.type !== 'none').map(pkg => [pkg.name, pkg.newVersion]).sort(), + [[core, '0.83.1'], [lists, '0.83.1']].sort()); + version(); + for (const index of [0, 1]) { + const pkg = JSON.parse(readFileSync(join(root, `packages/p${index}/package.json`), 'utf8')); + assert.equal(pkg.version, '0.83.1'); + assert.ok(!pkg.private); + assert.match(readFileSync(join(root, `packages/p${index}/CHANGELOG.md`), 'utf8'), /^## 0\.83\.1$/m); + } + for (const [index, pkg] of workspaces.entries()) { + if (pkg.private) { + assert.deepEqual(JSON.parse(readFileSync(join(root, `packages/p${index}/package.json`), 'utf8')), pkg); + } + } + assert.deepEqual((await getReleasePlan(root)).changesets, []); +}); + +test('repository Changesets policy accepts a private lists fixture and skips its release', async t => { + const workspaces = graph('1000.0.0'); + workspaces[1].private = true; + // A public package can use skipped private packages as development dependencies. + workspaces[0].devDependencies = {[lists]: workspaces[0].dependencies[lists]}; + delete workspaces[0].dependencies[lists]; + const {root} = releaseFixture(t, {workspaces, config: stablePolicy}); + assert.deepEqual((await getReleasePlan(root)).releases, []); + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${core}": patch\n---\n\nFix core.\n`); + const bumped = (await getReleasePlan(root)).releases.filter(pkg => pkg.type !== 'none'); + assert.deepEqual(bumped.map(pkg => [pkg.name, pkg.newVersion]), [[core, '1000.0.1']]); + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${lists}": patch\n---\n\nFix private lists.\n`); + assert.deepEqual((await getReleasePlan(root)).releases, []); +}); + +test('repository Changesets policy couples public stable packages without registry or private release edges', async t => { + const {root, workspaces} = releaseFixture(t, {config: stablePolicy}); + for (const changed of [core, lists, '@react-native/codegen', 'react-native-macos-init']) { + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${changed}": patch\n---\n\nFix package.\n`); + const releases = (await getReleasePlan(root)).releases.map(pkg => [pkg.name, pkg.newVersion]).sort(); + assert.deepEqual(releases, changed === 'react-native-macos-init' + ? [[changed, '2.1.4']] + : changed === '@react-native/codegen' ? [] : [[core, '0.83.3'], [lists, '0.83.3']].sort()); + } + // A consumer outside the fixed group proves that only workspace edges propagate. + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${lists}": patch\n---\n\nFix lists.\n`); + for (const range of ['0.83.2', 'workspace:*']) { + writeFileSync(join(root, 'packages/p4/package.json'), JSON.stringify({ + ...workspaces[4], dependencies: {[lists]: range}, + })); + const release = (await getReleasePlan(root)).releases.find(pkg => pkg.name === 'react-native-macos-init'); + assert.equal(release?.newVersion, range === 'workspace:*' ? '2.1.4' : undefined); + } +}); + +test('real Yarn constraints preserve private upstream versions, align public versions, and preserve workspace fork edges', t => { + for (const main of [true, false]) { + const workspaces = graph(main ? '1000.0.0' : '0.83.2'); + workspaces[1].private = main; + workspaces[1].version = '0.82.0'; + workspaces[2].version = '0.82.7'; + workspaces[3].version = '0.82.0'; + for (const index of [0, 1, 3]) { + for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { + workspaces[index][field] = {'@react-native/codegen': '*'}; + if (index !== 1) workspaces[index][field][lists] = '*'; + } + } + if (!main) workspaces[0].peerDependencies['react-native'] = '0.83.1'; + const {root} = releaseFixture(t, {workspaces}); + writeFileSync(join(root, 'yarn.lock'), ''); + writeFileSync(join(root, 'yarn.config.cjs'), `module.exports = require(${JSON.stringify(join(repositoryRoot, 'yarn.config.cjs'))});\n`); + const yarn = args => execFileSync(process.execPath, [join(repositoryRoot, '.yarn/releases/yarn-4.12.0.cjs'), ...args], { + cwd: root, encoding: 'utf8', env: {...process.env, YARN_IGNORE_PATH: '1', + YARN_ENABLE_NETWORK: '0', YARN_ENABLE_IMMUTABLE_INSTALLS: '0', YARN_ENABLE_SCRIPTS: '0'}, + }); + yarn(['install']); + yarn(['constraints', '--fix']); + yarn(['constraints']); + const actual = workspaces.map((_, index) => JSON.parse(readFileSync(join(root, `packages/p${index}/package.json`), 'utf8'))); + assert.equal(actual[0].version, workspaces[0].version); + assert.equal(actual[1].version, main ? '1000.0.0' : '0.83.2'); + assert.equal(actual[2].private, true); + assert.equal(actual[2].version, '0.82.7'); + assert.equal(actual[3].version, '1000.0.0'); + assert.equal(actual[5].private, true); + assert.equal(actual[5].version, main ? '0.83.0' : '0.83.1'); + for (const index of [0, 1, 3]) { + for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { + assert.equal(actual[index][field]['@react-native/codegen'], main || index === 3 ? 'workspace:*' : '0.83.1'); + if (index !== 1) assert.equal(actual[index][field][lists], 'workspace:*'); + } + } + } +}); + +test('real get-release-plan reads a prepared graph without a Git base or pending changesets', async t => { + const {root, workspaces} = releaseFixture(t); + const status = await readChangesetStatus(root); + assert.deepEqual(status.changesets, []); + assert.deepEqual(status.releases, []); + assert.equal((await plan({workspaces, status})).packages.length, 2); + writeFileSync(join(root, '.changeset/empty.md'), '---\n{}\n---\n'); + const pending = await readChangesetStatus(root); + assert.equal(pending.changesets.length, 1); + assert.deepEqual((await plan({workspaces, status: pending})).packages, []); +}); + +for (const changed of [core, lists]) { + test(`real Changesets version aligns both changelogs for a ${changed}-only patch`, async t => { + const {root, workspaces} = releaseFixture(t); + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${changed}": patch\n---\n\nFix release fixture.\n`); + const status = await readChangesetStatus(root); + const alignment = releaseAlignmentChangeset(workspaces, status); + if (alignment) writeFileSync(join(root, '.changeset/align.md'), alignment); + const aligned = await readChangesetStatus(root); + for (const name of [core, lists]) { + assert.equal(aligned.releases.find(pkg => pkg.name === name).newVersion, '0.83.3'); + } + const originalConfig = readFileSync(join(root, '.changeset/config.json'), 'utf8'); + await withReleaseConfig(() => { + execFileSync(process.execPath, [require.resolve('@changesets/cli/bin.js'), 'version'], { + cwd: root, encoding: 'utf8', env: {...process.env, CI: 'true'}, + }); + }, root); + assert.equal(readFileSync(join(root, '.changeset/config.json'), 'utf8'), originalConfig); + for (const index of [0, 1]) { + const pkg = JSON.parse(readFileSync(join(root, `packages/p${index}/package.json`), 'utf8')); + assert.equal(pkg.version, '0.83.3'); + assert.match(readFileSync(join(root, `packages/p${index}/CHANGELOG.md`), 'utf8'), /^## 0\.83\.3$/m); + } + assert.equal(JSON.parse(readFileSync(join(root, 'packages/p4/package.json'), 'utf8')).version, '2.1.3'); + assert.deepEqual((await readChangesetStatus(root)).changesets, []); + }); +} + +test('workspace discovery follows Yarn metadata, including workspaces outside packages/', t => { + const root = mkdtempSync(join(tmpdir(), 'rnm-package-graph-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + const packages = graph(); + const locations = packages.map((pkg, index) => `tools/workspace-${index}`); + for (const [index, location] of locations.entries()) { + mkdirSync(join(root, location), {recursive: true}); + writeFileSync(join(root, location, 'package.json'), JSON.stringify(packages[index])); + } + assert.deepEqual(readWorkspaces(root, (command, args) => { + assert.equal(command, 'yarn'); + assert.deepEqual(args, ['workspaces', 'list', '--json']); + return locations.map(location => JSON.stringify({location})).join('\n') + '\n'; + }), packages); +}); + +test('new public coupled workspaces receive one upload tag; init/private/upstream packages stay excluded', async () => { + const workspaces = graph(); + workspaces.push({name: '@react-native-macos/new-package', version: '0.83.2'}); + const result = await plan({workspaces}); + assert.deepEqual(result.packages, [ + {name: lists, version: '0.83.2', tag: 'latest'}, + {name: core, version: '0.83.2', tag: 'latest'}, + {name: '@react-native-macos/new-package', version: '0.83.2', tag: 'latest'}, + ]); +}); + +test('postbump applies shared constraints before artifacts and lockfile', async () => { + let workspaces = graph('0.83.1'); + const events = []; + await versionWithPostbump({branch, getWorkspaces: () => workspaces, + withConfig: callback => callback(), + prepareAlignment: () => () => {}, + run: (command, args) => { + events.push(args.join(' ')); + if (args[0] === 'changeset') workspaces = graph('0.83.2'); + }, + updateArtifacts: async version => {events.push(`artifacts ${version}`);}, + }); + assert.deepEqual(events, ['changeset version', 'constraints --fix', 'artifacts 0.83.2', 'install --mode update-lockfile']); +}); + +test('postbump rejects an invalid graph before artifacts or lockfile', async () => { + await assert.rejects(versionWithPostbump({branch, getWorkspaces: () => graph('1000.0.0'), + withConfig: callback => callback(), + prepareAlignment: () => () => {}, + run: (command, args) => assert.notEqual(args[0], 'install'), + updateArtifacts: () => assert.fail('Updated artifacts with an invalid graph'), + }), /Invalid release version/); +}); + +test('an init-only bump does not regenerate React Native artifacts', async () => { + const workspaces = graph(); + await versionWithPostbump({branch, getWorkspaces: () => workspaces, + withConfig: callback => callback(), + prepareAlignment: () => () => {}, + run: (command, args) => { + if (args[0] === 'changeset') workspaces.find(pkg => pkg.name === 'react-native-macos-init').version = '2.1.4'; + }, + updateArtifacts: () => assert.fail('Updated artifacts for init'), + }); +}); + +test('CLI skips main and pull requests without installed dependencies or registry access', () => { + const script = new URL('../../../.ado/scripts/configure-publish.mts', import.meta.url); + for (const env of [{GITHUB_REF_NAME: 'main'}, {GITHUB_REF_NAME: branch, GITHUB_BASE_REF: branch}]) { + const output = execFileSync(process.execPath, [script.pathname, '--publish'], { + env: {...process.env, ...env, GITHUB_OUTPUT: '', TF_BUILD: ''}, encoding: 'utf8', + }); + assert.match(output, /Publication disabled/); + } +}); + +test('patch alignment includes every coupled package; stable minor and major bumps fail', () => { + for (const name of [core, lists]) { + assert.match(releaseAlignmentChangeset(graph(), {releases: [{name, type: 'patch'}]}), + new RegExp(`"${name === core ? lists : core}": patch`)); + for (const type of ['minor', 'major']) { + assert.throws(() => releaseAlignmentChangeset(graph(), {releases: [{name, type}]}), /only patch/); + } + } + for (const releases of [ + [], + [{name: core, type: 'patch'}, {name: lists, type: 'patch'}], + [{name: 'react-native-macos-init', type: 'major'}], + [{name: '@react-native-macos/internal', type: 'major'}], + ]) { + assert.equal(releaseAlignmentChangeset(graph(), {releases}), undefined); + } +}); + +test('postbump removes its temporary core Changeset if Changesets fails', async () => { + let cleaned = false; + await assert.rejects(versionWithPostbump({branch, getWorkspaces: graph, + withConfig: callback => callback(), + prepareAlignment: () => () => {cleaned = true;}, + run: () => {throw new Error('Changesets failed');}, + updateArtifacts: () => assert.fail('Updated artifacts after failure'), + }), /Changesets failed/); + assert.equal(cleaned, true); +}); + +test('ADO stage and reusable job both retain a hard-false publication condition', () => { + for (const path of ['../../../.ado/publish.yml', '../../../.ado/jobs/npm-publish.yml']) { + assert.match(readFileSync(new URL(path, import.meta.url), 'utf8'), /^\s+condition: false$/m); + } +}); + +test('full SemVer prevents tag regression by patch, prerelease number, line, and current pointer', async () => { + for (const [version, tag, versions, tags] of [ + ['0.83.2', 'latest', ['0.83.10'], {}], + ['0.83.2', branch, ['0.83.3', '0.84.0'], {}], + ['0.83.2-rc.2', 'next', ['0.83.2-rc.10'], {}], + ['0.83.2-rc.10', 'next', ['0.84.0-rc.1'], {}], + ['0.83.2-rc.2', 'next', [], {next: '0.83.2'}], + ['0.83.2', 'latest', ['0.83.1'], {latest: '0.83.3'}], + ]) { + assert.equal(canAdvanceTag(version, tag, metadata(versions, tags)), false); + } + assert.equal(canAdvanceTag('0.83.2-rc.10', 'next', metadata(['0.83.2-rc.2'])), true); + assert.equal(canAdvanceTag('0.83.2+build.2', 'latest', metadata(['0.83.2+build.1'])), true); + assert.equal(canAdvanceTag('0.83.2', branch, metadata(['0.84.0'])), true); + for (const version of ['0.83.2', '0.83.2-rc.2']) { + await assert.rejects(plan({workspaces: graph(version), getMetadata: async name => + metadata(name === lists ? [version.includes('-') ? '0.83.2-rc.10' : '0.83.10'] : [])}), /non-monotonic/); + } +}); + +test('tag choice is per package when a partial newer-line publication exists', async () => { + const result = await plan({getMetadata: async name => metadata(name === lists ? ['0.84.0'] : ['0.82.0'])}); + assert.deepEqual(result.packages.map(pkg => [pkg.name, pkg.tag]), [[lists, branch], [core, 'latest']]); +}); + +test('existing old versions neither republish nor regress any tag', async () => { + const result = await plan({getMetadata: async () => metadata(['0.83.2', '0.83.10'], {latest: '0.83.10', [branch]: '0.83.10'})}); + assert.deepEqual(result.packages, []); + publishPrepared(result, () => assert.fail('Mutated an existing old version')); +}); + +test('existing versions skip cleanly with absent, older, or different tag pointers', async () => { + for (const tags of [{}, {latest: '0.83.1'}, {next: '0.83.2'}, {latest: '0.84.0'}]) { + const state = metadata(['0.83.2'], tags); + const before = structuredClone(state); + const result = await plan({getMetadata: async () => state}); + publishPrepared(result, () => assert.fail('Published or retagged an existing version')); + assert.deepEqual(result, {packages: [], tag: 'latest'}); + assert.deepEqual(state, before); + } +}); + +test('newest stable, old-line patch, and prerelease each use one publish call without separate tag mutations', async () => { + for (const [version, versions, tag] of [ + ['0.83.0', ['0.82.9'], 'latest'], + ['0.83.2', ['0.83.0', '0.84.0'], branch], + ['0.83.3-rc.1', ['0.83.2'], 'next'], + ]) { + const result = await plan({workspaces: graph(version), getMetadata: async () => metadata(versions)}); + const calls = []; + publishPrepared(result, (command, args) => calls.push([command, args])); + assert.deepEqual(calls, [lists, core].map(name => [ + 'yarn', ['workspace', name, 'npm', 'publish', '--provenance', '--tag', tag, '--tolerate-republish'], + ])); + assert.equal(result.tag, tag); + } +}); + +test('postbump rejects incomplete alignment before constraints and rejects later version overrides', async () => { + for (const override of [false, true]) { + let workspaces = graph(); + await assert.rejects(versionWithPostbump({branch, getWorkspaces: () => workspaces, + withConfig: callback => callback(), prepareAlignment: () => () => {}, + run: (command, args) => { + if (args[0] === 'changeset') { + if (override) workspaces = graph('0.83.3'); + else workspaces[0].version = '0.83.3'; + } + if (args[0] === 'constraints') { + assert.equal(override, true, 'Constraints hid incomplete Changesets alignment'); + workspaces = graph('0.83.4'); + } + assert.notEqual(args[0], 'install'); + }, updateArtifacts: () => assert.fail('Generated inconsistent artifacts'), + }), /does not match|Constraints changed/); + } +}); + +test('temporary Changesets config restores original bytes on failure', async t => { + const {root} = releaseFixture(t); + const path = join(root, '.changeset/config.json'); + const original = readFileSync(path, 'utf8'); + await assert.rejects(withReleaseConfig(() => {throw new Error('failure');}, root), /failure/); + assert.equal(readFileSync(path, 'utf8'), original); +}); + +test('stale-head check accepts only the event SHA on the same stable branch and fails closed', () => { + const env = {GITHUB_REF: 'refs/heads/0.83-stable', GITHUB_SHA: 'a'.repeat(40)}; + assert.equal(isCurrentHead(env, (command, args) => { + assert.equal(command, 'git'); + assert.deepEqual(args, ['ls-remote', '--exit-code', 'origin', env.GITHUB_REF]); + return `${env.GITHUB_SHA}\t${env.GITHUB_REF}\n`; + }), true); + assert.equal(isCurrentHead(env, () => `${'b'.repeat(40)}\t${env.GITHUB_REF}`), false); + assert.equal(isCurrentHead({...env, GITHUB_REF: 'refs/heads/main'}, () => assert.fail()), false); + assert.throws(() => isCurrentHead(env, () => {throw new Error('network');}), /network/); +}); + +function preparedFixture(t, { + oldVersion = '0.83.1', version = '0.83.2', target = branch, + privateLists = false, head = 'arbitrary-release-name', + editBase = () => {}, editHead = () => {}, +} = {}) { + // Let the real Changesets API inspect invalid private links without rejecting + // skipped dependencies first. The contract must still reject those links. + const {root} = releaseFixture(t, {versionPrivatePackages: true}); + const git = args => execFileSync('git', args, { + cwd: root, encoding: 'utf8', + env: {...process.env, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com', + GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com'}, + }); + const writePackage = (index, pkg) => writeFileSync(join(root, `packages/p${index}/package.json`), JSON.stringify(pkg)); + const writeChangelog = (index, text) => writeFileSync(join(root, `packages/p${index}/CHANGELOG.md`), text); + const old = graph(oldVersion); + old[1].private = privateLists; + old.forEach((pkg, index) => writePackage(index, pkg)); + for (const index of [0, 1]) writeChangelog(index, `# Changelog\n\n## ${oldVersion}\n\nOld release.\n`); + writeFileSync(join(root, '.changeset/bootstrap.md'), `---\n"${core}": patch\n---\n\nPrepare release.\n`); + editBase({root, workspaces: old, writePackage, writeChangelog}); + git(['init', '-q', '-b', target]); + const commit = () => { + git(['add', '.']); + git(['-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'Fixture state']); + }; + commit(); + const base = git(['rev-parse', 'HEAD']).trim(); + git(['switch', '-qc', head]); + const workspaces = graph(version); + const locations = workspaces.map((pkg, index) => `packages/p${index}`); + workspaces.forEach((pkg, index) => writePackage(index, pkg)); + for (const index of [0, 1]) { + writeChangelog(index, `# Changelog\n\n## ${version}\n\n### Patch Changes\n\n- Prepare release.\n\n## ${oldVersion}\n\nOld release.\n`); + } + rmSync(join(root, '.changeset/bootstrap.md')); + editHead({root, workspaces, locations, writePackage, writeChangelog}); + commit(); + const run = (command, args, options) => { + if (command === 'git') return execFileSync(command, args, options); + assert.equal(command, 'yarn'); + assert.deepEqual(args, ['workspaces', 'list', '--json']); + assert.equal(options.cwd, root); + return locations.map(location => JSON.stringify({location})).join('\n'); + }; + const validate = (overrides = {}) => validatePreparedVersionPR({root, baseBranch: target, run, ...overrides}); + return {root, git, commit, base, validate, run, writePackage, writeChangelog}; +} + +test('prepared bootstrap accepts every current public package, including private-to-public lists, on matching stable lines', async t => { + for (const minor of [81, 83, 84]) { + const fixture = preparedFixture(t, {oldVersion: '1000.0.0', version: `0.${minor}.0`, + target: `0.${minor}-stable`, privateLists: true}); + assert.equal(await fixture.validate(), true); + } +}); + +test('prepared patch validates real merge-base evidence when the target advances independently', async t => { + const fixture = preparedFixture(t); + fixture.git(['switch', '-q', branch]); + for (const index of [0, 1]) { + fixture.writePackage(index, graph('0.83.9')[index]); + fixture.writeChangelog(index, '# Changelog\n\n## 0.83.2\n\nUnrelated target history.\n'); + } + fixture.commit(); + fixture.git(['switch', '-q', 'arbitrary-release-name']); + assert.equal(fixture.git(['merge-base', branch, 'HEAD']).trim(), fixture.base); + assert.equal(await fixture.validate(), true); +}); + +test('prepared transitions require an increase or the exact bootstrap version', async t => { + for (const [oldVersion, version, target] of [ + ['0.83.2', '0.83.2', branch], + ['0.83.3', '0.83.2', branch], + ['0.83.2+build.1', '0.83.2+build.2', branch], + ['1000.0.0', '0.83.1', branch], + ['1000.0.0', '0.83.0-rc.1', branch], + ['1000.0.0', '0.83.0+build.1', branch], + ['1000.0.1', '0.83.0', branch], + ['1000.0.0-rc.1', '0.83.0', branch], + ['1000.0.0', '1.0.0', '1.0-stable'], + ['1000.0.0', '0.84.0', branch], + ]) { + const fixture = preparedFixture(t, {oldVersion, version, target}); + await assert.rejects(fixture.validate(), /Version must increase|Invalid release version|does not match/); + } + const fixture = preparedFixture(t, {oldVersion: '0.83.2-rc.1', version: '0.83.2'}); + assert.equal(await fixture.validate(), true); +}); + +test('every changed public package needs its own new nonempty version section', async t => { + for (const index of [0, 1]) { + for (const text of [ + undefined, + '# Changelog\n\n## 0.83.1\n\nOld release.\n', + '# Changelog\n\n## 0.83.2\n\n### Patch Changes\n\n\n\n## 0.83.1\n\nOld release.\n', + '# Changelog\n\n## 0.83.2\n\nOne.\n\n## 0.83.2\n\nTwo.\n', + ]) { + const fixture = preparedFixture(t, {editHead: ({root, writeChangelog}) => { + if (text === undefined) rmSync(join(root, `packages/p${index}/CHANGELOG.md`)); + else writeChangelog(index, text); + }}); + await assert.rejects(fixture.validate(), /changelog section|CHANGELOG\.md/i); + } + const fixture = preparedFixture(t, {editBase: ({writeChangelog}) => { + writeChangelog(index, '# Changelog\n\n## 0.83.2\n\nExisting release.\n'); + }}); + await assert.rejects(fixture.validate(), /new changelog section/); + } +}); + +test('reconstructed HTML comments are not release evidence, but visible notes remain valid', async t => { + for (const comment of [ + '', + '<!-- hidden -->', + '<!-- hidden -->', + '<<!---->!-- hidden -->', + ]) { + for (const notes of ['', '- Visible release note.']) { + const fixture = preparedFixture(t, {editHead: ({writeChangelog}) => { + writeChangelog(0, `# Changelog\n\n## 0.83.2\n\n${comment}\n${notes}\n`); + }}); + if (notes) assert.equal(await fixture.validate(), true); + else await assert.rejects(fixture.validate(), /Missing nonempty new changelog section/); + } + } +}); + +test('a new changelog file is valid, but an absent base manifest is not a version transition', async t => { + const fixture = preparedFixture(t, {editBase: ({root}) => { + for (const index of [0, 1]) rmSync(join(root, `packages/p${index}/CHANGELOG.md`)); + }}); + assert.equal(await fixture.validate(), true); + const missing = preparedFixture(t, {editBase: ({root}) => { + rmSync(join(root, 'packages/p1/package.json')); + }}); + await assert.rejects(missing.validate(), /Missing merge-base version/); +}); + +test('private-to-public lists cannot reuse the old version or omit their release notes', async t => { + for (const mode of ['same-version', 'missing-notes']) { + const fixture = preparedFixture(t, {privateLists: true, editHead: ({writePackage, writeChangelog}) => { + if (mode === 'same-version') writePackage(1, graph('0.83.1')[1]); + else writeChangelog(1, '# Changelog\n'); + }}); + await assert.rejects(fixture.validate(), /does not match|new changelog section/); + } + const fixture = preparedFixture(t, {privateLists: true, editBase: ({writePackage}) => { + writePackage(1, {...graph('0.83.2')[1], private: true}); + }}); + await assert.rejects(fixture.validate(), /Version must increase for @react-native-macos\/virtualized-lists/); +}); + +test('prepared PR rejects mismatched releases and invalid private or out-of-scope runtime links', async t => { + for (const [edit, message] of [ + [pkg => {pkg.version = '0.83.3';}, + `${lists}@0.83.2 does not match 0.83.3`], + [pkg => {pkg.private = true;}, + 'Missing public react-native-macos workspace'], + [pkg => {pkg.dependencies = {'@react-native/codegen': 'workspace:*'};}, + `${core} has a private or out-of-scope runtime workspace dependency: @react-native/codegen`], + [pkg => {pkg.optionalDependencies = {'@react-native-macos/internal': '0.83.2'};}, + `${core} has a private runtime dependency: @react-native-macos/internal`], + [pkg => {pkg.peerDependencies = {'react-native-macos-init': 'workspace:*'};}, + `${core} has a private or out-of-scope runtime workspace dependency: react-native-macos-init`], + ]) { + const fixture = preparedFixture(t, {editHead: ({workspaces, writePackage}) => { + edit(workspaces[0]); + writePackage(0, workspaces[0]); + }}); + const status = await readChangesetStatus(fixture.root); + assert.deepEqual(status.changesets, []); + assert.deepEqual(status.releases, []); + await assert.rejects(fixture.validate(), {name: 'Error', message}); + } +}); + +test('all changed public packages must belong to the release group, including source-only changes', async t => { + for (const index of [4, 5]) { + const fixture = preparedFixture(t, {editHead: ({root}) => { + writeFileSync(join(root, `packages/p${index}/source.js`), 'export const changed = true;\n'); + }}); + await assert.rejects(fixture.validate(), /outside the release group/); + } + const fixture = preparedFixture(t, {editHead: ({root}) => { + writeFileSync(join(root, 'packages/p3/source.js'), 'export const privateChange = true;\n'); + }}); + assert.equal(await fixture.validate(), true); +}); + +test('source-only public changes cannot use a prepared-looking head name as an exemption', async t => { + const fixture = preparedFixture(t, {head: 'changeset-release/0.83-stable', editHead: ({root, writePackage, writeChangelog}) => { + for (const index of [0, 1]) { + writePackage(index, {...graph('0.83.1')[index], ...(index === 1 ? {private: false} : {})}); + writeChangelog(index, '# Changelog\n\n## 0.83.1\n\nOld release.\n'); + } + writeFileSync(join(root, 'packages/p0/source.js'), 'export const changed = true;\n'); + }}); + await assert.rejects(fixture.validate(), /Version must increase/); + assert.equal(await fixture.validate({branch: 'main'}), false); +}); + +test('pending API state always uses the normal check, including empty Changesets and release-only state', async t => { + const fixture = preparedFixture(t); + for (const status of [ + {changesets: [{id: 'pending', releases: [{name: core, type: 'patch'}]}], releases: []}, + {changesets: [{id: 'empty', releases: []}], releases: []}, + {changesets: [], releases: [{name: core, type: 'patch'}]}, + ]) { + let normalChecks = 0; + await runCheck(branch, { + validatePrepared: () => fixture.validate({ + run: () => assert.fail('Inspected Git or packages with pending Changesets'), + getStatus: root => readChangesetStatus(root, async (actualRoot, sinceRef, config) => { + assert.equal(actualRoot, fixture.root); + assert.equal(sinceRef, undefined); + assert.deepEqual(config, {bumpVersionsWithWorkspaceProtocolOnly: true}); + return status; + }), + }), + getStatus: async baseBranch => { + assert.equal(baseBranch, branch); + normalChecks++; + return {data: {releases: [], changesets: []}, exitCode: 0}; + }, + }); + assert.equal(normalChecks, 1); + } + writeFileSync(join(fixture.root, '.changeset/empty.md'), '---\n{}\n---\n'); + assert.equal(await fixture.validate(), false); +}); + +test('prepared check shares validation with the CLI and propagates API and Git errors', async t => { + const fixture = preparedFixture(t); + const getStatus = () => assert.fail('Ran normal check after prepared success or error'); + await runCheck(branch, {validatePrepared: () => fixture.validate(), getStatus}); + for (const overrides of [ + {getStatus: () => {throw new Error('release API failed');}}, + {getStatus: () => ({})}, + {baseBranch: 'missing/0.83-stable'}, + {run: () => {throw new Error('command failed');}}, + ]) { + await assert.rejects(runCheck(branch, {validatePrepared: () => fixture.validate(overrides), getStatus}), + /release API failed|Invalid Changesets status|Not a valid object name|command failed/); + } + await assert.rejects(runCheck(branch, {validatePrepared: async () => false, + getStatus: async () => {throw new Error('normal check failed');}}), /normal check failed/); +}); + +test('normal check still rejects missing Changesets and major bumps', async t => { + t.mock.method(process, 'exit', code => {throw new Error(`Exit ${code}`);}); + for (const result of [ + {data: {releases: [], changesets: []}, exitCode: 1}, + {data: {releases: [{name: core, type: 'major', changesets: ['breaking']}], changesets: ['breaking']}, exitCode: 0}, + ]) { + await assert.rejects(runCheck(branch, {validatePrepared: async () => false, + getStatus: async () => result}), /Exit 1/); + } +}); + +test('no changed public package uses the normal check', async t => { + const fixture = preparedFixture(t, {editHead: ({writePackage, writeChangelog}) => { + for (const index of [0, 1]) { + writePackage(index, {...graph('0.83.1')[index], ...(index === 1 ? {private: false} : {})}); + writeChangelog(index, '# Changelog\n\n## 0.83.1\n\nOld release.\n'); + } + }}); + assert.equal(await fixture.validate(), false); +}); + +test('a valid prepared bump cannot hide a deleted unrelated public workspace', async t => { + for (const index of [4, 5]) { + const fixture = preparedFixture(t, {editHead: ({root, locations}) => { + rmSync(join(root, `packages/p${index}`), {recursive: true}); + locations.splice(index, 1); + }}); + await assert.rejects(fixture.validate(), /Deleted or moved public workspace/); + } +}); + +test('a valid prepared bump cannot hide a public workspace moved into another workspace or to a new location', async t => { + for (const destination of ['packages/p0/fixtures/moved', 'packages/moved']) { + const fixture = preparedFixture(t, {editHead: ({root, locations}) => { + mkdirSync(join(root, 'packages/p0/fixtures'), {recursive: true}); + renameSync(join(root, 'packages/p4'), join(root, destination)); + if (destination === 'packages/moved') locations[4] = destination; + else locations.splice(4, 1); + }}); + await assert.rejects(fixture.validate(), /Deleted or moved public workspace: react-native-macos-init/); + } +}); + +test('base workspace membership detects a public package excluded only at HEAD', async t => { + const fixture = preparedFixture(t, {editHead: ({root, locations}) => { + const path = join(root, 'package.json'); + const pkg = JSON.parse(readFileSync(path, 'utf8')); + pkg.workspaces.push('!packages/p4'); + writeFileSync(path, JSON.stringify(pkg)); + locations.splice(4, 1); + }}); + await assert.rejects(fixture.validate(), /Deleted or moved public workspace: react-native-macos-init/); +}); + +test('deleted private workspaces and non-workspace fixture manifests do not invalidate a prepared bump', async t => { + const extras = ['fixtures/public', 'packages/p0/fixtures/public', 'packages/p0/node_modules/public', + 'packages/excluded', 'tools/node_modules/public']; + for (const objectConfig of [false, true]) { + const fixture = preparedFixture(t, { + editBase: ({root}) => { + const path = join(root, 'package.json'); + const pkg = JSON.parse(readFileSync(path, 'utf8')); + const patterns = ['packages/*', 'tools/**', '!packages/excluded']; + pkg.workspaces = objectConfig ? {packages: patterns} : patterns; + writeFileSync(path, JSON.stringify(pkg)); + for (const location of extras) { + mkdirSync(join(root, location), {recursive: true}); + // Invalid JSON proves the validator does not read unrelated manifests. + writeFileSync(join(root, location, 'package.json'), 'not a workspace manifest'); + } + }, + editHead: ({root, locations}) => { + rmSync(join(root, 'packages/p3'), {recursive: true}); + locations.splice(3, 1); + for (const location of extras) rmSync(join(root, location), {recursive: true}); + }, + }); + assert.equal(await fixture.validate(), true); + } +}); + +test('base workspace manifest and tree command errors propagate', async t => { + const fixture = preparedFixture(t); + for (const fail of [ + args => args[0] === 'ls-tree', + args => args[0] === 'show' && args[1] === `${fixture.base}:package.json`, + args => args[0] === 'show' && args[1] === `${fixture.base}:packages/p4/package.json`, + ]) { + const error = new Error('Base workspace Git failure'); + await assert.rejects(fixture.validate({run: (command, args, options) => { + if (command === 'git' && fail(args)) throw error; + return fixture.run(command, args, options); + }}), actual => actual === error); + } +}); diff --git a/.github/scripts/__tests__/publishing-workflow.test.mjs b/.github/scripts/__tests__/publishing-workflow.test.mjs new file mode 100644 index 000000000000..893d2ab7a1b6 --- /dev/null +++ b/.github/scripts/__tests__/publishing-workflow.test.mjs @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import {execFileSync, fork} from 'node:child_process'; +import {once} from 'node:events'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {test} from 'node:test'; +import {stripVTControlCharacters} from 'node:util'; + +const require = createRequire(import.meta.url); +const {load} = createRequire(require.resolve('eslint'))('js-yaml'); +// An optional trusted root lets the shared test execute an equivalent release worktree's workflows. +const repositoryRoot = resolve(process.env.PUBLISH_WORKFLOW_ROOT ?? new URL('../../../', import.meta.url).pathname); +const yarnPath = join(repositoryRoot, '.yarn/releases/yarn-4.12.0.cjs'); +const core = 'react-native-macos'; +const lists = '@react-native-macos/virtualized-lists'; +const workflow = name => load(readFileSync(join(repositoryRoot, `.github/workflows/${name}.yml`), 'utf8')); +const publishSteps = workflow('microsoft-npm-publish').jobs.publish.steps; +const dryRunSteps = workflow('microsoft-pr').jobs['npm-publish-dry-run'].steps; + +async function fixture(t, {eligible = '1', fail = '', privateLists = false, enableColors} = {}) { + const root = mkdtempSync(join(tmpdir(), 'rnm-publish-workflow-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + const write = (path, contents) => { + mkdirSync(dirname(join(root, path)), {recursive: true}); + writeFileSync(join(root, path), contents); + }; + const json = (path, value) => write(path, JSON.stringify(value)); + json('package.json', {name: 'fixture', private: true, workspaces: ['packages/*'], scripts: { + build: 'node fixture.cjs tooling', 'build-types': 'node fixture.cjs types', + }}); + for (const [directory, name, isPrivate] of [ + ['core', core, false], ['lists', lists, privateLists], + ['codegen', '@react-native/codegen', true], + ['init', 'react-native-macos-init', false], ['upstream', '@react-native/unrelated', false], + ['internal', '@react-native-macos/internal', true], + ]) { + json(`packages/${directory}/package.json`, {name, version: '1.0.0', private: isPrivate, + files: ['types_generated'], + ...(name === core ? {dependencies: {[lists]: 'workspace:*'}} : {}), + scripts: name === '@react-native/codegen' ? {build: 'node ../../fixture.cjs codegen'} + : {prepack: `node ../../fixture.cjs pack ${directory}`}, + }); + } + write('snapshot', 'checked-in API\n'); + write('yarn.lock', ''); + // --tolerate-republish queries metadata even during a dry run. A local + // registry returns 404 and rejects any attempted upload. + write('registry.cjs', ` + require('node:http').createServer((req, res) => { + if (req.method !== 'GET') { + require('node:fs').writeFileSync(__dirname + '/upload-attempt', req.method); + } + res.writeHead(req.method === 'GET' ? 404 : 500); + res.end('{}'); + }).listen(0, '127.0.0.1', function () { process.send(this.address().port); }); + `); + const registry = fork(join(root, 'registry.cjs'), [], {stdio: ['ignore', 'ignore', 'inherit', 'ipc']}); + t.after(() => registry.kill()); + const [port] = await once(registry, 'message'); + write('.fixture-yarnrc.yml', `npmRegistryServer: "http://127.0.0.1:${port}"\nunsafeHttpWhitelist:\n - 127.0.0.1\n`); + // Synthetic build outputs isolate workflow sequencing from the full compiler. + // Real Yarn runs the workspace selector, prepack hooks, and package dry run. + write('fixture.cjs', ` + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const path = require('node:path'); + const root = __dirname; + const [command, arg] = process.argv.slice(2); + const has = file => fs.existsSync(path.join(root, file)); + const record = value => fs.appendFileSync(path.join(root, 'events'), value + '\\n'); + assert.notEqual(command, process.env.FAIL, 'Injected build failure'); + if (command === 'tooling' || command === 'codegen') { + fs.writeFileSync(path.join(root, command), 'built'); + record(command); + } else if (command === 'types') { + assert.ok(has('tooling') && has('codegen'), 'Types ran before local builds'); + assert.equal(arg, '--validate', 'Snapshot must be validated'); + assert.equal(fs.readFileSync(path.join(root, 'snapshot'), 'utf8'), 'checked-in API\\n'); + for (const pkg of ['core', 'lists']) { + const output = path.join(root, 'packages', pkg, 'types_generated'); + fs.mkdirSync(output); + fs.writeFileSync(path.join(output, 'index.d.ts'), 'export {};'); + } + record('types'); + } else if (command === 'pack') { + assert.ok(['core', 'lists'].includes(arg), 'Packed an unrelated package'); + assert.ok(has('tooling') && has('codegen'), 'Packed without local builds'); + assert.ok(has('packages/' + arg + '/types_generated/index.d.ts'), 'Packed without generated types'); + record('pack ' + arg); + } else { + assert.fail('Unexpected fixture command: ' + command); + } + `); + // The contract suite covers eligibility semantics. Here its output exercises + // the actual workflow conditions, including skipped and failed preparation. + write('.ado/scripts/configure-publish.mts', ` + import assert from 'node:assert/strict'; + import {appendFileSync, existsSync} from 'node:fs'; + const publish = process.argv.includes('--publish'); + appendFileSync('events', publish ? 'publish\\n' : 'preview\\n'); + if (publish) { + assert.equal(process.env.ELIGIBLE, '1'); + for (const pkg of ['core', 'lists']) { + assert.ok(existsSync('packages/' + pkg + '/types_generated/index.d.ts')); + } + } else { + appendFileSync(process.env.GITHUB_OUTPUT, 'publish_react_native_macos=' + process.env.ELIGIBLE + '\\n'); + } + `); + const env = {...process.env, ELIGIBLE: eligible, FAIL: fail, + GITHUB_OUTPUT: join(root, 'outputs'), YARN_IGNORE_PATH: '1', + YARN_RC_FILENAME: '.fixture-yarnrc.yml', YARN_ENABLE_NETWORK: '1', YARN_ENABLE_IMMUTABLE_INSTALLS: '0', + YARN_ENABLE_HARDENED_MODE: '0', YARN_NPM_AUTH_TOKEN: 'fixture-only', + YARN_NPM_REGISTRY_SERVER: `http://127.0.0.1:${port}`, + YARN_NPM_PUBLISH_REGISTRY: `http://127.0.0.1:${port}`, + ...(enableColors === undefined ? {} : {FORCE_COLOR: enableColors, YARN_ENABLE_COLORS: enableColors}), + }; + const run = command => execFileSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', + `node_path=$1\nyarn_path=$2\nyarn() { "$node_path" "$yarn_path" "$@"; }\n${command}`, + 'publishing-workflow', process.execPath, yarnPath], + {cwd: root, env, encoding: 'utf8', stdio: 'pipe'}); + run('yarn install'); + const events = () => existsSync(join(root, 'events')) ? readFileSync(join(root, 'events'), 'utf8').trim().split('\n') : []; + const execute = steps => { + for (const step of steps) { + if (step.if) { + assert.equal(step.if, "steps.configure-publish.outputs.publish_react_native_macos == '1'"); + assert.ok(existsSync(env.GITHUB_OUTPUT), 'Preparation ran before eligibility'); + if (!readFileSync(env.GITHUB_OUTPUT, 'utf8').includes('publish_react_native_macos=1\n')) continue; + } + const output = stripVTControlCharacters(run(step.run)); + if (step.run.includes('npm publish')) { + assert.match(step.run, /--dry-run\b/); + for (const name of privateLists ? [core] : [lists, core]) { + assert.ok(output.includes(`[${name}]: ➤ YN0000: types_generated/index.d.ts`), + `Dry-run package omitted generated types: ${name}\n${output}`); + } + } + } + assert.equal(existsSync(join(root, 'upload-attempt')), false, 'Dry run attempted an upload'); + }; + assert.equal(existsSync(join(root, 'packages/core/types_generated')), false); + return {root, events, execute}; +} + +const releasePreparation = publishSteps.slice(publishSteps.findIndex(step => step.id === 'configure-publish')); +const dryRunPreparation = dryRunSteps.slice(dryRunSteps.findIndex(step => step.run === 'yarn build')); + +test('publish workflow previews eligibility, builds local tools and validated types, then publishes', async t => { + const f = await fixture(t); + f.execute(releasePreparation); + assert.deepEqual(f.events(), ['preview', 'tooling', 'codegen', 'types', 'publish']); + assert.equal(readFileSync(join(f.root, 'snapshot'), 'utf8'), 'checked-in API\n'); +}); + +test('ineligible publication skips every build and the publish command', async t => { + const f = await fixture(t, {eligible: '0'}); + f.execute(releasePreparation); + assert.deepEqual(f.events(), ['preview']); +}); + +test('build and snapshot validation failures stop release and dry-run publication', async t => { + for (const steps of [releasePreparation, dryRunPreparation]) { + for (const fail of ['tooling', 'codegen', 'types']) { + const f = await fixture(t, {fail}); + assert.throws(() => f.execute(steps), /Injected build failure/); + assert.ok(f.events().every(event => event !== 'publish' && !event.startsWith('pack '))); + } + } +}); + +test('PR dry run packs only public coupled workspaces with generated types from a clean fixture', async t => { + for (const enableColors of ['0', '1']) { + for (const privateLists of [false, true]) { + const f = await fixture(t, {privateLists, enableColors}); + f.execute(dryRunPreparation); + assert.deepEqual(f.events(), ['tooling', 'codegen', 'types', + ...(privateLists ? [] : ['pack lists']), 'pack core']); + assert.equal(readFileSync(join(f.root, 'snapshot'), 'utf8'), 'checked-in API\n'); + } + } +}); + +test('PR dry run rejects generated types omitted from the package file list', async t => { + const f = await fixture(t, {enableColors: '1'}); + const path = join(f.root, 'packages/lists/package.json'); + const pkg = JSON.parse(readFileSync(path, 'utf8')); + writeFileSync(path, JSON.stringify({...pkg, files: ['package.json']})); + assert.throws(() => f.execute(dryRunPreparation), /Dry-run package omitted generated types/); +}); + +test('workflow root paths with shell syntax remain literal arguments', t => { + const root = mkdtempSync(join(tmpdir(), 'rnm-workflow-path-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + const literalRoot = join(root, 'repository with spaces \' " $HOME $(exit 97) `exit 98`'); + symlinkSync(repositoryRoot, literalRoot, 'dir'); + const env = {...process.env, PUBLISH_WORKFLOW_ROOT: literalRoot}; + delete env.NODE_TEST_CONTEXT; + const output = execFileSync(process.execPath, ['--test', '--test-reporter=tap', '--test-name-pattern=^ineligible publication', + new URL(import.meta.url).pathname], { + env, encoding: 'utf8', stdio: 'pipe', + }); + assert.match(output, /# pass 1\b/); +}); diff --git a/.github/scripts/__tests__/resolve-hermes-test.js b/.github/scripts/__tests__/resolve-hermes-test.js new file mode 100644 index 000000000000..c511dc055768 --- /dev/null +++ b/.github/scripts/__tests__/resolve-hermes-test.js @@ -0,0 +1,185 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +'use strict'; + +const {spawnSync} = require('child_process'); +const fs = require('fs'); +const ini = require('ini'); +const os = require('os'); +const path = require('path'); + +const root = path.resolve(__dirname, '../../..'); +const metadata = ini.parse( + fs.readFileSync( + path.join( + root, + 'packages/react-native/sdks/hermes-engine/version.properties', + ), + 'utf8', + ), +); +const script = path.join(root, '.github/scripts/resolve-hermes.mts'); +const preload = path.join(__dirname, '__fixtures__/resolve-hermes.cjs'); +let tmp; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-hermes-test-')); +}); + +afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); +}); + +function run(command, overrides = {}) { + const env = {...process.env}; + for (const key of [ + 'RCT_HERMES_V1_ENABLED', + 'HERMES_VERSION', + 'HERMES_ENGINE_TARBALL_PATH', + 'HERMES_TEST_PROPERTIES', + 'HERMES_TEST_DOWNLOAD', + ]) { + delete env[key]; + } + const outputPath = path.join(tmp, 'output'); + const result = spawnSync( + process.execPath, + ['--experimental-strip-types', '--require', preload, script, ...command], + { + cwd: tmp, + env: {...env, TMPDIR: tmp, GITHUB_OUTPUT: outputPath, ...overrides}, + encoding: 'utf8', + timeout: 10000, + }, + ); + if (result.error) { + throw result.error; + } + const output = fs.existsSync(outputPath) + ? fs.readFileSync(outputPath, 'utf8') + : ''; + const urls = JSON.parse(result.stdout.match(/HERMES_TEST_URLS=(.*)/)[1]); + return {...result, output, urls}; +} + +test.each([ + ['0', metadata.HERMES_VERSION_NAME, 'HERMES_VERSION_NAME', 'Debug'], + ['1', metadata.HERMES_V1_VERSION_NAME, 'HERMES_V1_VERSION_NAME', 'Release'], +])( + 'CI downloads flag %s with the selected key and version', + (flag, version, key, flavor) => { + const result = run(['download-hermes', flavor], { + RCT_HERMES_V1_ENABLED: flag, + HERMES_TEST_DOWNLOAD: 'release', + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain(`Using ${key}=${version}`); + expect(result.output).toContain(`version=${version}\n`); + expect(result.urls).toEqual([ + `https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/${version}-SNAPSHOT/maven-metadata.xml`, + `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/${version}/hermes-ios-${version}-hermes-ios-${flavor.toLowerCase()}.tar.gz`, + ]); + const tarball = result.output.match(/^tarball=(.+)$/m)[1]; + expect(fs.readFileSync(tarball, 'utf8')).toBe('mock Hermes archive'); + }, +); + +test('CI snapshot fallback preserves the four-argument URL helper contract', () => { + const version = '123.4.56'; + const result = run(['download-hermes'], { + HERMES_TEST_DOWNLOAD: 'snapshot', + HERMES_TEST_PROPERTIES: `HERMES_VERSION_NAME=${version}`, + }); + expect(result.status).toBe(0); + expect(result.output).toContain(`version=${version}\n`); + expect(result.urls).toEqual([ + `https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/${version}-SNAPSHOT/maven-metadata.xml`, + `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/${version}/hermes-ios-${version}-hermes-ios-debug.tar.gz`, + `https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/${version}-SNAPSHOT/hermes-ios-${version}-20260101.010203-4-hermes-ios-debug.tar.gz`, + ]); +}); + +test('CI can still select source when valid pinned artifacts are unavailable', () => { + const result = run(['download-hermes']); + expect(result.status).toBe(0); + expect(result.output).toBe(''); + expect(result.stdout).toContain('will build from source'); + expect(result.urls).toHaveLength(2); +}); + +test.each([ + ['', 'Expected one exact HERMES_VERSION_NAME'], + ['UNREADABLE', 'EACCES'], + ['HERMES_VERSION_NAME=^1.2.3', 'Expected one exact HERMES_VERSION_NAME'], + [ + 'HERMES_VERSION_NAME=1.2.3\nHERMES_VERSION_NAME=1.2.3', + 'Expected one exact HERMES_VERSION_NAME', + ], +])('CI fails invalid metadata before any download: %s', (properties, error) => { + const result = run(['download-hermes'], {HERMES_TEST_PROPERTIES: properties}); + expect(result.status).toBe(1); + expect(result.stderr).toContain(error); + expect(result.output).toBe(''); + expect(result.urls).toEqual([]); + expect(result.stdout).not.toContain('will build from source'); +}); + +test('CI selects source without a download when version.properties is missing', () => { + const result = run(['download-hermes'], {HERMES_TEST_PROPERTIES: 'MISSING'}); + expect(result.status).toBe(0); + expect(result.output).toBe(''); + expect(result.stdout).toContain('will build from source'); + expect(result.urls).toEqual([]); +}); + +test.each([ + [undefined, '.hermesversion'], + ['0', '.hermesversion'], + ['1', '.hermesv1version'], +])('CI resolve-commit uses the tag file for flag %s', (flag, tagFile) => { + const result = run( + ['resolve-commit'], + flag == null ? {} : {RCT_HERMES_V1_ENABLED: flag}, + ); + const tag = fs + .readFileSync( + path.join(root, 'packages/react-native/sdks', tagFile), + 'utf8', + ) + .trim(); + expect(result.status).toBe(0); + expect(result.output).toBe(`hermes-commit=${tag}\n`); + expect(result.urls).toEqual([]); +}); + +test.each([ + ['0', '.hermesversion', 'MISSING'], + ['1', '.hermesv1version', 'MISSING'], + ['1', '.hermesv1version', 'HERMES_VERSION_NAME=123.4.56'], + ['0', '.hermesversion', 'HERMES_VERSION_NAME=invalid'], +])( + 'CI resolve-commit reads flag %s tag %s independently of metadata %s', + (flag, tagFile, properties) => { + const result = run(['resolve-commit'], { + RCT_HERMES_V1_ENABLED: flag, + HERMES_TEST_PROPERTIES: properties, + }); + const tag = fs + .readFileSync( + path.join(root, 'packages/react-native/sdks', tagFile), + 'utf8', + ) + .trim(); + expect(result.status).toBe(0); + expect(result.output).toBe(`hermes-commit=${tag}\n`); + expect(result.urls).toEqual([]); + }, +); diff --git a/.github/scripts/change.mts b/.github/scripts/change.mts index fa0d858f5a9b..731e2b98dabe 100644 --- a/.github/scripts/change.mts +++ b/.github/scripts/change.mts @@ -1,17 +1,20 @@ #!/usr/bin/env node // @ts-ignore import { parseArgs, styleText } from 'node:util'; +import { pathToFileURL } from 'node:url'; +import { join } from 'node:path'; import { $, echo, fs } from 'zx'; +import { validatePreparedVersionPR } from './publishing-contract.mjs'; /** * Wrapper around `changeset add` (default) and `changeset status` validation (--check). * * Without --check: runs `changeset add` interactively with the correct upstream remote - * auto-detected from package.json's repository URL, temporarily patched into config.json. + * auto-detected from repository metadata and the base branch from Changesets config. * * With --check (CI mode): validates that all changed public packages have changesets and that - * no major version bumps are introduced. + * no major version bumps are introduced, or validates a fully prepared version PR. */ interface ChangesetStatusOutput { @@ -33,21 +36,32 @@ const log = { }; /** Find the remote that matches the repo's own URL (works for forks and CI alike). */ -async function getBaseBranch(): Promise { - const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); - const repoUrl: string = pkg.repository?.url ?? ''; +export async function getBaseBranch(root = process.cwd()): Promise { + const pkg = fs.readJsonSync(join(root, 'package.json')); + let repoUrl: string = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url ?? ''; + const coreManifest = join(root, 'packages/react-native/package.json'); + if (!repoUrl && fs.existsSync(coreManifest)) { + const core = fs.readJsonSync(coreManifest); + repoUrl = typeof core.repository === 'string' ? core.repository : core.repository?.url ?? ''; + } // Extract "org/repo" from https://github.com/org/repo.git or git@github.com:org/repo.git - const repoPath = repoUrl.match(/github\.com[:/](.+?)(?:\.git)?$/)?.[1] ?? ''; + const repoPath = (url: string) => url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/i)?.[1]?.toLowerCase(); + const repository = repoPath(repoUrl); - const remotes = (await $`git remote -v`.quiet()).stdout; - const remote = (repoPath && remotes.match(new RegExp(`^(\\S+)\\s+.*${repoPath}`, 'm'))?.[1]) ?? 'origin'; + const remotes = (await $({ cwd: root })`git remote -v`.quiet()).stdout; + const remote = (repository && remotes.trim().split('\n') + .map(line => line.split(/\s+/)) + .find(([, url, kind]) => kind === '(fetch)' && repoPath(url) === repository)?.[0]) || 'origin'; // In CI, use the PR target branch (e.g., origin/0.81-stable) if (process.env['GITHUB_BASE_REF']) { return `${remote}/${process.env['GITHUB_BASE_REF']}`; } - return `${remote}/main`; + const config = fs.readJsonSync(join(root, '.changeset/config.json')); + const baseBranch: string = config.baseBranch ?? 'origin/main'; + // origin is the shared config's checkout remote; preserve explicit local overrides. + return baseBranch.startsWith('origin/') ? `${remote}/${baseBranch.slice('origin/'.length)}` : baseBranch; } /** Run `changeset status` and return the output and exit code. */ @@ -79,10 +93,18 @@ function checkMajorBumps(releases: ChangesetStatusOutput['releases']): void { } /** Validate that all changed public packages have changesets and no major bumps are introduced. */ -async function runCheck(baseBranch: string): Promise { +export async function runCheck(baseBranch: string, { + validatePrepared = validatePreparedVersionPR, + getStatus = getChangesetStatus, +} = {}): Promise { log.info(`Validating changesets against ${baseBranch}...\n`); - const { data, exitCode } = await getChangesetStatus(baseBranch); + if (await validatePrepared({baseBranch})) { + log.success('All validations passed (prepared version PR)'); + return; + } + + const { data, exitCode } = await getStatus(baseBranch); if (exitCode !== 0) { log.error('Some packages have been changed but no changesets were found.'); @@ -101,12 +123,14 @@ async function runAdd(baseBranch: string): Promise { await $({ stdio: 'inherit' })`yarn changeset --since ${baseBranch}`; } -const { values: args } = parseArgs({ options: { check: { type: 'boolean', default: false } } }); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const { values: args } = parseArgs({ options: { check: { type: 'boolean', default: false } } }); -const baseBranch = await getBaseBranch(); + const baseBranch = await getBaseBranch(); -if (args.check) { - await runCheck(baseBranch); -} else { - await runAdd(baseBranch); + if (args.check) { + await runCheck(baseBranch); + } else { + await runAdd(baseBranch); + } } diff --git a/.github/scripts/changeset-version-with-postbump.mts b/.github/scripts/changeset-version-with-postbump.mts index 18ebde4b0756..68f4f58b4844 100644 --- a/.github/scripts/changeset-version-with-postbump.mts +++ b/.github/scripts/changeset-version-with-postbump.mts @@ -1,19 +1,96 @@ #!/usr/bin/env node -import { $, echo, fs } from 'zx'; -import { updateReactNativeArtifacts } from '../../scripts/releases/set-rn-artifacts-version.js'; +import {execFileSync} from 'node:child_process'; +import {randomUUID} from 'node:crypto'; +import {readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {readChangesetStatus, readWorkspaces, releasePackages, validateRelease, validateReleaseVersions} from './publishing-contract.mjs'; +import {isCurrentHead} from './check-version-head.mjs'; -// Step 1: Run changeset version to bump package.json files and update CHANGELOGs -echo('📦 Running changeset version...'); -await $`yarn changeset version`; +// Stable branches accept patch releases only. Every coupled package must be in +// the Changesets plan so each changelog describes the version actually published. +export function releaseAlignmentChangeset(workspaces, status) { + const names = new Set(releasePackages(workspaces).map(pkg => pkg.name)); + const bumped = new Set(); + for (const release of status.releases) { + if (!names.has(release.name)) continue; + if (!['none', 'patch'].includes(release.type)) { + throw new Error(`Stable release policy permits only patch bumps: ${release.name} ${release.type}`); + } + if (release.type === 'patch') bumped.add(release.name); + } + const missing = [...names].filter(name => !bumped.has(name)); + return bumped.size && missing.length + ? `---\n${missing.map(name => `"${name}": patch`).join('\n')}\n---\n\nAlign the React Native macOS release with its public workspace packages.\n` + : undefined; +} -// Step 2: Update native artifacts to match the new react-native version -echo('\n🔄 Updating React Native native artifacts...'); -const { version } = fs.readJsonSync('packages/react-native/package.json'); -await updateReactNativeArtifacts(version); -echo('✅ Native artifacts updated'); +async function prepareReleaseAlignment(workspaces) { + const contents = releaseAlignmentChangeset(workspaces, await readChangesetStatus()); + if (!contents) return () => {}; + const path = `.changeset/rnm-alignment-${randomUUID()}.md`; + writeFileSync(path, contents, {flag: 'wx'}); + return () => rmSync(path, {force: true}); +} -// Step 4: Update yarn.lock to reflect all changes -echo('\n🔒 Updating yarn.lock...'); -await $`yarn install --mode update-lockfile`; +export async function withReleaseConfig(callback, root = process.cwd()) { + const path = join(root, '.changeset/config.json'); + const original = readFileSync(path, 'utf8'); + const config = JSON.parse(original); + // Match the API adapter. Otherwise the CLI refuses explicit registry deps on + // private upstream workspaces, even though they are not local release edges. + writeFileSync(path, JSON.stringify({...config, bumpVersionsWithWorkspaceProtocolOnly: true}, null, 2) + '\n'); + try { + return await callback(); + } finally { + writeFileSync(path, original); + } +} -echo('\n✅ Version bump complete!'); +export async function versionWithPostbump({ + run = execFileSync, + getWorkspaces = readWorkspaces, + prepareAlignment = prepareReleaseAlignment, + withConfig = withReleaseConfig, + branch = process.env.GITHUB_REF_NAME ?? execFileSync('git', ['branch', '--show-current'], {encoding: 'utf8'}).trim(), + updateArtifacts = async (version: string) => { + const {updateReactNativeArtifacts} = await import('../../scripts/releases/set-rn-artifacts-version.js'); + await updateReactNativeArtifacts(version); + }, +} = {}) { + const before = getWorkspaces(); + validateReleaseVersions(before, branch); + const oldVersion = before.find(pkg => pkg.name === 'react-native-macos')?.version; + await withConfig(async () => { + const cleanup = await prepareAlignment(before); + try { + run('yarn', ['changeset', 'version'], {stdio: 'inherit'}); + } finally { + cleanup(); + } + }); + + // Reject incomplete Changesets alignment before constraints can hide it. + const versioned = validateReleaseVersions(getWorkspaces(), branch); + // Apply shared dependency/private-workspace constraints before artifacts. These + // constraints must not change public release versions after changelog generation. + run('yarn', ['constraints', '--fix'], {stdio: 'inherit'}); + const packages = validateRelease(getWorkspaces(), branch); + for (const pkg of versioned) { + if (packages.find(candidate => candidate.name === pkg.name)?.version !== pkg.version) { + throw new Error(`Constraints changed the Changesets version of ${pkg.name}`); + } + } + const {version} = packages.find(pkg => pkg.name === 'react-native-macos'); + if (version !== oldVersion) await updateArtifacts(version); + + run('yarn', ['install', '--mode', 'update-lockfile'], {stdio: 'inherit'}); + console.log('Version bump complete'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await versionWithPostbump(); + if (process.env.GITHUB_ACTIONS === 'true' && !isCurrentHead()) { + throw new Error('Stable branch advanced during the version bump; a newer workflow must update the PR'); + } +} diff --git a/.github/scripts/check-release-published.mjs b/.github/scripts/check-release-published.mjs new file mode 100644 index 000000000000..946b0fd5682a --- /dev/null +++ b/.github/scripts/check-release-published.mjs @@ -0,0 +1,27 @@ +import {execFileSync} from 'node:child_process'; +import {appendFileSync} from 'node:fs'; +import {pathToFileURL} from 'node:url'; + +export function isReleasePublished(minor, query = execFileSync) { + if (!/^\d+\.\d+$/.test(minor)) { + throw new Error(`Invalid React Native minor: ${minor}`); + } + // Query the package, not a potentially missing version range. A successful + // response with no matching version is distinct from a failed registry query. + const versions = JSON.parse(query('npm', ['view', 'react-native-macos', 'versions', '--json'], { + encoding: 'utf8', + timeout: 60000, + })); + if (!Array.isArray(versions) || !versions.every(version => typeof version === 'string')) { + throw new Error('Invalid npm versions response'); + } + return versions.some(version => version.startsWith(`${minor}.`)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const published = isReleasePublished(process.argv[2]); + console.log(`react-native-macos ${process.argv[2]}.x published: ${published}`); + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `published=${published}\n`); + } +} diff --git a/.github/scripts/check-version-head.mjs b/.github/scripts/check-version-head.mjs new file mode 100644 index 000000000000..9f55dde0c00a --- /dev/null +++ b/.github/scripts/check-version-head.mjs @@ -0,0 +1,20 @@ +import {execFileSync} from 'node:child_process'; +import {appendFileSync} from 'node:fs'; +import {pathToFileURL} from 'node:url'; + +export function isCurrentHead(env = process.env, run = execFileSync) { + if (!/^refs\/heads\/\d+\.\d+-stable$/.test(env.GITHUB_REF ?? '') || !env.GITHUB_SHA) { + return false; + } + const remote = run('git', ['ls-remote', '--exit-code', 'origin', env.GITHUB_REF], { + encoding: 'utf8', timeout: 60000, + }).trim(); + const [sha, ref] = remote.split(/\s+/); + return sha === env.GITHUB_SHA && ref === env.GITHUB_REF; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const current = isCurrentHead(); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `current=${current}\n`); + console.log(current ? 'Version workflow matches the stable branch head' : 'Skip stale or non-stable version workflow'); +} diff --git a/.github/scripts/export-versions.mts b/.github/scripts/export-versions.mts index f2658034cb8e..27bd54804285 100644 --- a/.github/scripts/export-versions.mts +++ b/.github/scripts/export-versions.mts @@ -1,4 +1,5 @@ #!/usr/bin/env node +// [macOS] /** * Export react and react-native version information from packages/react-native/package.json. * @@ -7,6 +8,7 @@ * react_native_version – the coerced major.minor React Native version (e.g. "0.79") */ import * as fs from "node:fs"; +import codegenManifest from "../../packages/react-native-codegen/package.json" with { type: "json" }; import manifest from "../../packages/react-native/package.json" with { type: "json" }; function coerce(version: string): string { @@ -24,7 +26,12 @@ function exportValue(name: string, value: string): void { } } -const { dependencies, peerDependencies } = manifest; +const { peerDependencies } = manifest; exportValue("react_version", peerDependencies["react"]); -exportValue("react_native_version", coerce(dependencies["@react-native/codegen"])); +// Stable branches declare upstream compatibility explicitly. Fork-point branches +// retain it in the codegen workspace version, not the "workspace:*" dependency. +const reactNativeVersion = + (peerDependencies as Record)["react-native"] ?? + codegenManifest.version; +exportValue("react_native_version", coerce(reactNativeVersion)); diff --git a/.github/scripts/publishing-contract.md b/.github/scripts/publishing-contract.md new file mode 100644 index 000000000000..43cd64cbc27c --- /dev/null +++ b/.github/scripts/publishing-contract.md @@ -0,0 +1,55 @@ +# React Native macOS publication contract + +- `microsoft-changesets-version.yml` automatically creates the Changesets version PR. +- `microsoft-npm-publish.yml` publishes prepared versions on stable-branch pushes through Yarn 4.12 Trusted Publishing. +- `.ado/publish.yml` and `.ado/jobs/npm-publish.yml` retain hard-false conditions. ADO publication remains disabled until explicitly re-enabled. + +## Scope and preparation + +The automatic release consists of `react-native-macos` and public `@react-native-macos/*` workspaces discovered through Yarn. `react-native-macos-init` has an independent release process and is excluded, even when its local version is unpublished. + +The version wrapper permits only patch bumps for the coupled release packages on stable branches. It adds a temporary Changeset for every coupled package absent from a patch plan. Changesets then generates every release version and changelog together. The wrapper rejects mismatched versions before `yarn constraints --fix`, and rejects any subsequent public version override by those constraints. Shared dependency constraints still run before native artifacts and the lockfile. An init-only bump does not regenerate core artifacts. + +Pending Changesets come from the declared `@changesets/get-release-plan` API, with no `sinceRef`; the CLI's default base-branch comparison is not used. The API and version wrapper use `bumpVersionsWithWorkspaceProtocolOnly: true`, because explicit registry references to private upstream workspaces are external dependencies. The wrapper restores the original Changesets configuration after the CLI returns, including on failure. + +Stable branches must already have their initial release version and React Native peer configured. The publication script does not turn `1000.0.0` into a release. All public release packages must match the core version and branch. Private runtime workspace links are invalid; explicit registry references to the separately published upstream `@react-native/*` packages are valid. Development-only private workspace links are allowed. + +Main and merge-stage branches keep the `1000.0.0` development graph, including private virtualized-lists and upstream workspace links. Their Changesets config defers `react-native-macos` with `ignore`; `@react-native/tester` is also listed for compatibility with older Changesets' dependent validation. This preserves pending core changesets until stable preparation, without changing package privacy or enabling private versions or tags. The independent init package remains versionable. Stable preparation must clear `ignore`, set the stable `baseBranch`, make virtualized-lists public, and configure the release versions and upstream registry dependencies before versioning. The repository-graph test checks these branch-specific source settings. + +## Publication and tags + +Any pending Changeset, including an empty Changeset, skips publication. Registry failures fail the run. The script validates the complete package graph and queries every selected package before publication. It publishes only absent versions in dependency order, so retries skip versions already published. A release consists of multiple npm writes, not an atomic registry transaction. + +A new upload receives exactly one npm tag, matching the single-tag policy in `9cc1f0aeca8`. A real prerelease uses `next`. A stable version uses `latest` unless that package already has a stable version from a newer release line. An older release line uses its branch tag, such as `0.83-stable`. Full SemVer comparison checks each package's published versions and current tag pointers. An unpublished older patch or prerelease fails before any package is published if its tag would regress. + +For example, `0.83.0` publishes with `latest` when it is the newest stable line. After `0.84.0` exists, a new `0.83` patch publishes with `0.83-stable`. Yarn applies that one tag during publication; there is no separate tag call. + +Existing versions skip successfully, including partial retries and versions with absent or different tags. The workflow does not promote existing versions or repair tags. The original `.ado/scripts/apply-additional-tags.mjs` remains retained and inactive behind the disabled ADO route. Trusted Publishing needs no additional credentials for this single-tag policy. + +## Concurrency + +Publication remains push-triggered. Its global concurrency group uses `queue: max`, which [GitHub.com documents](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) as up to 100 pending runs. Overflow runs are canceled; queue order is the order runs start waiting, not necessarily push order. Registry monotonicity checks therefore remain necessary. No manual trigger was added. + +Changesets uses a separate per-branch concurrency group with `cancel-in-progress: true`. It checks the remote stable head before the action and again after the version script, to reject stale reruns or a branch advance during preparation. A branch can still advance after the final check; concurrency cancellation limits that race but is not a compare-and-swap update of the version PR. + +Each selected package needs an npm Trusted Publisher for: + +- Repository: `microsoft/react-native-macos` +- Workflow: `microsoft-npm-publish.yml` +- Environment: `npm-publish` +- Direct publication permission + +These remote package settings cannot be established by the local tests. See [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers/). + +## Checks + +Run with Node 22.22.0: + +```sh +node --test .github/scripts/__tests__/publishing-contract.test.mjs +actionlint .github/workflows/microsoft-npm-publish.yml .github/workflows/microsoft-changesets-version.yml +``` + +The tests use real `get-release-plan` and real Changesets version commands on temporary package graphs, including absent Git base refs and both core-only and scoped-only changes. They verify both generated changelogs. Registry publication remains mocked; tests verify one tag per upload and no separate tag mutations. + +`actionlint` 1.7.12 does not recognize the documented `queue` property. Its unfiltered run reports that one syntax diagnostic; use a newer supporting release when available. This is a local lint compatibility limitation, not proof of a successful GitHub workflow run. diff --git a/.github/scripts/publishing-contract.mjs b/.github/scripts/publishing-contract.mjs new file mode 100644 index 000000000000..5fd5e2c11fa9 --- /dev/null +++ b/.github/scripts/publishing-contract.mjs @@ -0,0 +1,334 @@ +import {execFileSync} from 'node:child_process'; +import {readFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {join} from 'node:path'; + +const require = createRequire(import.meta.url); +const semver = require('semver'); +const micromatch = require('micromatch'); + +export const registry = 'https://registry.npmjs.org'; + +export function parseVersion(version) { + const parsed = typeof version === 'string' && semver.parse(version); + if (!parsed || parsed.major === 1000 || !/^\d/.test(version)) { + throw new Error(`Invalid release version: ${version}`); + } + return {major: parsed.major, minor: parsed.minor, prerelease: parsed.prerelease.length > 0}; +} + +export function isStableBranch(branch) { + return /^(0|[1-9]\d*)\.(0|[1-9]\d*)-stable$/.test(branch); +} + +function readWorkspaceEntries(root, run) { + const output = run('yarn', ['workspaces', 'list', '--json'], { + cwd: root, + encoding: 'utf8', + }); + return output.trim().split('\n').map(line => { + const {location} = JSON.parse(line); + return {location, pkg: JSON.parse(readFileSync(join(root, location, 'package.json'), 'utf8'))}; + }); +} + +export function readWorkspaces(root = process.cwd(), run = execFileSync) { + return readWorkspaceEntries(root, run).map(({pkg}) => pkg); +} + +export function validateChangesetConfig({ + root = process.cwd(), + config = JSON.parse(readFileSync(join(root, '.changeset/config.json'), 'utf8')), + workspaces = readWorkspaces(root), + baseRef = process.env.GITHUB_BASE_REF, +} = {}) { + const core = workspaces.find(pkg => pkg.name === 'react-native-macos'); + const lists = workspaces.find(pkg => pkg.name === '@react-native-macos/virtualized-lists'); + if (!core || !lists) { + throw new Error('Missing React Native macOS release workspaces'); + } + + const main = core.version === '1000.0.0'; + const parsed = !main && semver.parse(core.version); + if (!main && !parsed) { + throw new Error(`Invalid React Native macOS workspace version: ${core.version}`); + } + + const expectedBase = `origin/${baseRef ?? (main ? 'main' : `${parsed.major}.${parsed.minor}-stable`)}`; + const expectedIgnore = main ? ['react-native-macos', '@react-native/tester'] : []; + const expectedFixed = [['react-native-macos', '@react-native-macos/virtualized-lists']]; + const expectedPrivatePackages = {version: false, tag: false}; + const assertConfig = (name, actual, expected) => { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Invalid Changesets ${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } + }; + + assertConfig('baseBranch', config.baseBranch, expectedBase); + assertConfig('ignore', config.ignore, expectedIgnore); + assertConfig('fixed groups', config.fixed, expectedFixed); + assertConfig('linked groups', config.linked, []); + assertConfig('private package policy', config.privatePackages, expectedPrivatePackages); + assertConfig('workspace protocol policy', config.bumpVersionsWithWorkspaceProtocolOnly, true); + if (Boolean(lists.private) !== main) { + throw new Error(`@react-native-macos/virtualized-lists must be ${main ? 'private' : 'public'}`); + } + if (lists.version !== core.version) { + throw new Error(`@react-native-macos/virtualized-lists@${lists.version} does not match ${core.version}`); + } + + return {baseBranch: expectedBase, mode: main ? 'development' : 'stable'}; +} + +// The init CLI has its own version and release process. Never include all public +// workspaces: only these packages follow the React Native macOS release line. +export function releasePackages(workspaces) { + return workspaces.filter(pkg => !pkg.private && ( + pkg.name === 'react-native-macos' || pkg.name.startsWith('@react-native-macos/') + )); +} + +export function validateReleaseVersions(workspaces, branch) { + if (!isStableBranch(branch)) { + throw new Error(`Expected a stable branch, got: ${branch}`); + } + const packages = releasePackages(workspaces); + const core = packages.find(pkg => pkg.name === 'react-native-macos'); + if (!core) { + throw new Error('Missing public react-native-macos workspace'); + } + const version = parseVersion(core.version); + if (`${version.major}.${version.minor}-stable` !== branch) { + throw new Error(`Version ${core.version} does not match ${branch}`); + } + for (const pkg of packages) { + parseVersion(pkg.version); + if (pkg.version !== core.version) { + throw new Error(`${pkg.name}@${pkg.version} does not match ${core.version}`); + } + } + return packages; +} + +export function validateRelease(workspaces, branch) { + const packages = validateReleaseVersions(workspaces, branch); + const core = packages.find(pkg => pkg.name === 'react-native-macos'); + const byName = new Map(workspaces.map(pkg => [pkg.name, pkg])); + const selected = new Set(packages.map(pkg => pkg.name)); + for (const pkg of packages) { + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + for (const [name, range] of Object.entries(pkg[field] ?? {})) { + const dependency = byName.get(name); + // Private upstream workspaces are valid registry dependencies only when + // the manifest contains a registry range, not a local workspace link. + if (dependency?.private && !name.startsWith('@react-native/')) { + throw new Error(`${pkg.name} has a private runtime dependency: ${name}`); + } + if (/(^|[^\d])1000\./.test(range) || /^(file|link|portal):/.test(range)) { + throw new Error(`${pkg.name} has an unreleasable ${field} entry: ${name}@${range}`); + } + if (range.startsWith('workspace:') && (!dependency || dependency.private || !selected.has(name))) { + throw new Error(`${pkg.name} has a private or out-of-scope runtime workspace dependency: ${name}`); + } + if (selected.has(name) && ![ + core.version, `^${core.version}`, `~${core.version}`, + 'workspace:*', 'workspace:^', 'workspace:~', + `workspace:${core.version}`, `workspace:^${core.version}`, `workspace:~${core.version}`, + ].includes(range)) { + throw new Error(`${pkg.name} has a mismatched runtime dependency: ${name}@${range}`); + } + } + } + } + return packages; +} + +export async function readChangesetStatus(root = process.cwd(), getReleasePlan = require('@changesets/get-release-plan').default) { + // Unlike `changeset status`, this API does not default to config.baseBranch. + // Omit sinceRef to inspect ALL pending Changesets, including empty changesets. + // Explicit registry dependencies on private upstream workspaces are external + // releases. Only workspace: links participate in dependency bump propagation. + const status = await getReleasePlan(root, undefined, {bumpVersionsWithWorkspaceProtocolOnly: true}); + if (!Array.isArray(status.changesets) || !Array.isArray(status.releases)) { + throw new Error('Invalid Changesets status'); + } + return status; +} + +function changelogSection(changelog, version) { + const headings = [...changelog.matchAll(/^#{1,2} .+$/gm)]; + const matches = headings.filter(heading => heading[0].trim() === `## ${version}`); + if (matches.length > 1) throw new Error(`Duplicate changelog section: ${version}`); + if (!matches.length) return undefined; + const heading = matches[0]; + const next = headings[headings.indexOf(heading) + 1]; + let section = changelog.slice(heading.index + heading[0].length, next?.index); + // Removing a comment can reconstruct another comment delimiter. + while (//.test(section)) { + section = section.replace(//g, ''); + } + return section.replace(/^#{1,6} .+$/gm, '').trim(); +} + +// A consumed Changeset is valid only when the PR contains the complete release +// evidence. Head branch names are not evidence and never grant an exemption. +export async function validatePreparedVersionPR({ + baseBranch, + branch = baseBranch.split('/').at(-1), + root = process.cwd(), + run = execFileSync, + getStatus = readChangesetStatus, +}) { + const status = await getStatus(root); + if (!Array.isArray(status.changesets) || !Array.isArray(status.releases)) { + throw new Error('Invalid Changesets status'); + } + // Keep the normal check for pending releases, including empty Changesets. + if (status.changesets.length || status.releases.length || !isStableBranch(branch)) return false; + + const git = args => run('git', args, {cwd: root, encoding: 'utf8'}); + const mergeBase = git(['merge-base', baseBranch, 'HEAD']).trim(); + const changed = git(['diff', '--name-only', '--no-renames', '-z', mergeBase, 'HEAD']).split('\0').filter(Boolean); + const entries = readWorkspaceEntries(root, run); + const baseFiles = new Set(git(['ls-tree', '-r', '--name-only', '-z', mergeBase]).split('\0')); + const baseRoot = JSON.parse(git(['show', `${mergeBase}:package.json`])); + const patterns = Array.isArray(baseRoot.workspaces) ? baseRoot.workspaces : baseRoot.workspaces?.packages ?? []; + const baseLocations = micromatch([...baseFiles] + .filter(path => path.endsWith('/package.json')) + .map(path => path.slice(0, -'/package.json'.length)), patterns, { + dot: true, ignore: ['**/node_modules/**', '**/.git/**', '**/.yarn/**'], + }); + const byLocation = new Map(entries.map(({location, pkg}) => [location, pkg])); + // Current Yarn metadata cannot report a deleted workspace. Use the base root's + // workspace patterns, not arbitrary fixture manifests, to check lost packages. + for (const location of ['.', ...baseLocations]) { + const previous = location === '.' ? baseRoot : JSON.parse(git(['show', `${mergeBase}:${location}/package.json`])); + if (!previous.private && byLocation.get(location)?.name !== previous.name) { + throw new Error(`Deleted or moved public workspace: ${previous.name} (${location})`); + } + } + // Use current visibility: a private-to-public workspace needs release evidence. + const publicChanges = entries.filter(({location, pkg}) => !pkg.private && changed.some(path => + location === '.' || path.startsWith(`${location}/`))); + if (!publicChanges.length) return false; + + const selected = new Set(validateRelease(entries.map(({pkg}) => pkg), branch).map(pkg => pkg.name)); + for (const {location, pkg} of publicChanges) { + if (!selected.has(pkg.name)) { + throw new Error(`Changed public package is outside the release group: ${pkg.name}`); + } + const manifest = join(location, 'package.json'); + const changelog = join(location, 'CHANGELOG.md'); + if (!baseFiles.has(manifest)) { + throw new Error(`Missing merge-base version for ${pkg.name}`); + } + const previous = JSON.parse(git(['show', `${mergeBase}:${manifest}`])); + const current = JSON.parse(git(['show', `HEAD:${manifest}`])); + if (current.version !== pkg.version || current.name !== pkg.name || current.private) { + throw new Error(`Workspace differs from HEAD: ${pkg.name}`); + } + const bootstrap = previous.version === '1000.0.0' && pkg.version === `0.${parseVersion(pkg.version).minor}.0`; + if (!bootstrap) { + parseVersion(previous.version); + if (!semver.gt(pkg.version, previous.version)) { + throw new Error(`Version must increase for ${pkg.name}: ${previous.version} -> ${pkg.version}`); + } + } + const before = baseFiles.has(changelog) ? git(['show', `${mergeBase}:${changelog}`]) : ''; + if (changelogSection(before, pkg.version) !== undefined || + !changed.includes(changelog) || !changelogSection(git(['show', `HEAD:${changelog}`]), pkg.version)) { + throw new Error(`Missing nonempty new changelog section for ${pkg.name}@${pkg.version}`); + } + } + return true; +} + +export async function publishedMetadata(name, fetchRegistry = fetch) { + const response = await fetchRegistry(`${registry}/${encodeURIComponent(name)}`, { + signal: AbortSignal.timeout(60000), + }); + if (response.status === 404) return {versions: [], tags: {}}; + if (!response.ok) throw new Error(`Registry query failed for ${name}: ${response.status}`); + const metadata = await response.json(); + if (!metadata?.versions || typeof metadata.versions !== 'object' || Array.isArray(metadata.versions) || + !metadata['dist-tags'] || typeof metadata['dist-tags'] !== 'object' || Array.isArray(metadata['dist-tags'])) { + throw new Error(`Invalid registry metadata for ${name}`); + } + for (const version of [...Object.keys(metadata.versions), ...Object.values(metadata['dist-tags'])]) { + parseVersion(version); + } + return {versions: Object.keys(metadata.versions), tags: metadata['dist-tags']}; +} + +export function publishTag(version, branch, published) { + const current = parseVersion(version); + if (current.prerelease) return 'next'; + const newerLine = published.some(value => { + const other = parseVersion(value); + return !other.prerelease && (other.major > current.major || + (other.major === current.major && other.minor > current.minor)); + }); + return newerLine ? branch : 'latest'; +} + +// Never move a tag backwards, even if its current pointer is stale or absent. +// Compare full SemVer (including numeric prerelease identifiers), per package. +export function canAdvanceTag(version, tag, metadata) { + const target = parseVersion(version); + const candidates = metadata.versions.filter(value => { + const other = parseVersion(value); + if (tag === 'next') return other.prerelease; + if (tag === 'latest') return !other.prerelease; + return !other.prerelease && other.major === target.major && other.minor === target.minor; + }); + if (metadata.tags[tag]) candidates.push(metadata.tags[tag]); + return candidates.every(value => semver.gte(version, value)); +} + +export async function createPublishPlan({workspaces, branch, status, getMetadata = publishedMetadata}) { + if (!Array.isArray(status.changesets) || !Array.isArray(status.releases)) { + throw new Error('Invalid Changesets status'); + } + // Even an empty changeset must first pass through the automatic version PR. + if (status.changesets.length || status.releases.length) { + return {packages: [], reason: 'Pending Changesets; waiting for the version PR'}; + } + const packages = validateRelease(workspaces, branch); + const core = packages.find(pkg => pkg.name === 'react-native-macos'); + const published = new Map(); + for (const pkg of packages) published.set(pkg.name, await getMetadata(pkg.name)); + const tag = publishTag(core.version, branch, published.get(core.name).versions); + // Validate the complete graph before the first publish, including on retries. + const ordered = []; + const visiting = new Set(); + const visited = new Set(); + const byName = new Map(packages.map(pkg => [pkg.name, pkg])); + function visit(pkg) { + if (visited.has(pkg.name)) return; + if (visiting.has(pkg.name)) throw new Error(`Runtime dependency cycle: ${pkg.name}`); + visiting.add(pkg.name); + for (const name of Object.keys({...pkg.dependencies, ...pkg.optionalDependencies})) { + if (byName.has(name)) visit(byName.get(name)); + } + visiting.delete(pkg.name); + visited.add(pkg.name); + const metadata = published.get(pkg.name); + const exists = metadata.versions.includes(pkg.version); + if (!exists) { + const packageTag = publishTag(pkg.version, branch, metadata.versions); + if (!canAdvanceTag(pkg.version, packageTag, metadata)) { + throw new Error(`Refusing non-monotonic publication: ${pkg.name}@${pkg.version} -> ${packageTag}`); + } + ordered.push({name: pkg.name, version: pkg.version, tag: packageTag}); + } + } + for (const pkg of packages) visit(pkg); + return {packages: ordered, tag}; +} + +export function publishPrepared(plan, run = execFileSync) { + for (const pkg of plan.packages) { + run('yarn', ['workspace', pkg.name, 'npm', 'publish', '--provenance', + '--tag', pkg.tag, '--tolerate-republish'], {stdio: 'inherit'}); + } +} diff --git a/.github/scripts/resolve-hermes.mts b/.github/scripts/resolve-hermes.mts index 77fb5d9a1abe..83cc9c32fd54 100644 --- a/.github/scripts/resolve-hermes.mts +++ b/.github/scripts/resolve-hermes.mts @@ -16,6 +16,10 @@ import { $, echo, fs, path } from 'zx'; // Use createRequire to import CommonJS modules from ESM context const require = createRequire(import.meta.url); +const { + readHermesMetadata, + selectHermesMetadata, +} = require('../../packages/react-native/scripts/ios-prebuild/hermes-version.js'); const { computeNightlyTarballURL, } = require('../../packages/react-native/scripts/ios-prebuild/utils.js'); @@ -31,30 +35,19 @@ function setActionOutput(key: string, value: string) { * Reads the Hermes artifact version from * packages/react-native/sdks/hermes-engine/version.properties. * - * Returns HERMES_V1_VERSION_NAME when RCT_HERMES_V1_ENABLED=1, otherwise - * HERMES_VERSION_NAME. Returns null if the file or the key is missing. + * Uses the same version key and validation as the local prebuild script. + * A missing file permits a source build; malformed metadata must fail CI. */ function resolveHermesArtifactVersion(): string | null { - const propsPath = path.resolve( - import.meta.dirname!, '..', '..', - 'packages', 'react-native', 'sdks', 'hermes-engine', 'version.properties', - ); try { - const props: Record = {}; - for (const line of fs.readFileSync(propsPath, 'utf8').split('\n')) { - const eq = line.indexOf('='); - if (eq > 0) { - props[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); - } + const {version, versionKey} = readHermesMetadata(); + echo(`Using ${versionKey}=${version}`); + return version; + } catch (error: any) { + if (error.code === 'ENOENT') { + return null; } - const key = - process.env.RCT_HERMES_V1_ENABLED === '1' - ? 'HERMES_V1_VERSION_NAME' - : 'HERMES_VERSION_NAME'; - const version = props[key]; - return version != null && version.length > 0 ? version : null; - } catch { - return null; + throw error; } } @@ -64,10 +57,9 @@ function resolveHermesArtifactVersion(): string | null { * facebook/hermes. Returns null if the file is missing or empty. */ function resolveHermesTag(): string | null { - const tagFile = - process.env.RCT_HERMES_V1_ENABLED === '1' - ? '.hermesv1version' - : '.hermesversion'; + const {tagFile} = selectHermesMetadata( + 'legacy-default', process.env.RCT_HERMES_V1_ENABLED, + ); const tagPath = path.resolve( import.meta.dirname!, '..', '..', 'packages', 'react-native', 'sdks', tagFile, @@ -92,7 +84,7 @@ async function downloadUpstreamHermesTarball( ): Promise<{ tarballPath: string; version: string } | null> { const version = resolveHermesArtifactVersion(); if (version == null) { - echo('Could not read Hermes version from sdks/hermes-engine/version.properties'); + echo('Hermes version.properties is missing — will build from source.'); return null; } diff --git a/.github/scripts/validate-changeset-config.mjs b/.github/scripts/validate-changeset-config.mjs new file mode 100644 index 000000000000..55f4263a167a --- /dev/null +++ b/.github/scripts/validate-changeset-config.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import {validateChangesetConfig} from './publishing-contract.mjs'; + +const {baseBranch, mode} = validateChangesetConfig(); +console.log(`Changesets config is valid for ${mode} mode (${baseBranch}).`); diff --git a/.github/workflows/autorebase.yml b/.github/workflows/autorebase.yml index 1a3af07c31a0..447e7dc5ece5 100644 --- a/.github/workflows/autorebase.yml +++ b/.github/workflows/autorebase.yml @@ -25,4 +25,3 @@ jobs: uses: cirrus-actions/rebase@1.8 env: GITHUB_TOKEN: ${{ secrets.REACT_NATIVE_BOT_GITHUB_TOKEN }} - continue-on-error: true # [macOS] diff --git a/.github/workflows/microsoft-build-rntester.yml b/.github/workflows/microsoft-build-rntester.yml index e78f4669dcc2..67838728491d 100644 --- a/.github/workflows/microsoft-build-rntester.yml +++ b/.github/workflows/microsoft-build-rntester.yml @@ -60,6 +60,10 @@ jobs: continue-on-error: ${{ matrix.linkage == 'dynamic' }} env: RCT_NEW_ARCH_ENABLED: '1' + # Maven's upstream binaries only contain iOS slices. Build this fork's + # core and dependencies for each destination in the RNTester matrix. + RCT_USE_PREBUILT_RNCORE: '0' + RCT_USE_RN_DEP: '0' USE_FRAMEWORKS: ${{ matrix.use_frameworks }} run: | set -eox pipefail diff --git a/.github/workflows/microsoft-changesets-version.yml b/.github/workflows/microsoft-changesets-version.yml index 7d7ed8d1a030..00ae52403e10 100644 --- a/.github/workflows/microsoft-changesets-version.yml +++ b/.github/workflows/microsoft-changesets-version.yml @@ -6,6 +6,10 @@ on: - "*-stable" workflow_dispatch: +concurrency: + group: changesets-version-${{ github.ref }} + cancel-in-progress: true + jobs: version: name: Create Version Bump PR @@ -28,6 +32,9 @@ jobs: - name: Install dependencies run: yarn install --immutable + - name: Test publishing contract + run: node --test .github/scripts/__tests__/publishing-contract.test.mjs + - name: Generate token for version PR uses: actions/create-github-app-token@v2 id: app-token @@ -37,7 +44,12 @@ jobs: permission-contents: write # for GH releases and Git tags (Changesets) permission-pull-requests: write # version PRs (Changesets) + - name: Check stable branch head + id: current-head + run: node .github/scripts/check-version-head.mjs + - name: Create Version Bump PR + if: steps.current-head.outputs.current == 'true' uses: changesets/action@v1 with: version: yarn changeset:version diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/microsoft-codeql-analysis.yml similarity index 98% rename from .github/workflows/codeql-analysis.yml rename to .github/workflows/microsoft-codeql-analysis.yml index b94dad7fccea..2b5370ea8e94 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/microsoft-codeql-analysis.yml @@ -9,7 +9,7 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: "CodeQL" +name: "Microsoft CodeQL" on: push: diff --git a/.github/workflows/microsoft-npm-publish.yml b/.github/workflows/microsoft-npm-publish.yml index 3f196f0a8516..f6e9a5456834 100644 --- a/.github/workflows/microsoft-npm-publish.yml +++ b/.github/workflows/microsoft-npm-publish.yml @@ -5,6 +5,13 @@ on: branches: - "*-stable" +concurrency: + # Serialize release lines because they share the latest and next tags. + group: npm-publish + # GitHub.com supports up to 100 pending runs; do not replace a release push. + queue: max + cancel-in-progress: false + jobs: publish: name: Publish to npm @@ -34,26 +41,32 @@ jobs: - name: Install dependencies run: yarn install --immutable - - name: Verify release config + - name: Test publishing contract + run: node --test .github/scripts/__tests__/publishing-contract.test.mjs + + # Changesets prepares versions in its automatic PR. This step never bumps + # versions and skips pushes with pending changesets or no unpublished versions. + - name: Check publish eligibility id: configure-publish run: node .ado/scripts/configure-publish.mts --verbose - - name: Configure yarn for npm publishing + # Build local Node tooling even when its upstream packages are private. + - name: Build packages + if: steps.configure-publish.outputs.publish_react_native_macos == '1' + run: yarn build + + - name: Build local codegen + if: steps.configure-publish.outputs.publish_react_native_macos == '1' + run: yarn workspace @react-native/codegen build + + # --validate still generates types_generated, but never rewrites the API snapshot. + - name: Build and validate generated types if: steps.configure-publish.outputs.publish_react_native_macos == '1' - run: | - yarn config set npmPublishAccess public - yarn config set npmPublishRegistry "https://registry.npmjs.org" + run: yarn build-types --validate - - name: Publish packages + - name: Publish prepared packages if: steps.configure-publish.outputs.publish_react_native_macos == '1' - run: | - yarn workspaces foreach -vv --all --topological --no-private npm publish \ - --provenance \ - --tag "${{ steps.configure-publish.outputs.publishTag }}" \ - --tolerate-republish - - - name: Remove npm auth configuration - if: always() - run: | - yarn config unset npmPublishAccess || true - yarn config unset npmPublishRegistry || true + run: node .ado/scripts/configure-publish.mts --publish --verbose + env: + YARN_NPM_PUBLISH_ACCESS: public + YARN_NPM_PUBLISH_REGISTRY: https://registry.npmjs.org diff --git a/.github/workflows/microsoft-pr.yml b/.github/workflows/microsoft-pr.yml index 6b7e015073e1..7137217162fc 100644 --- a/.github/workflows/microsoft-pr.yml +++ b/.github/workflows/microsoft-pr.yml @@ -3,7 +3,7 @@ name: PR on: pull_request: types: [opened, synchronize, edited] - branches: [ "main", "*-stable", "release/*", "*-merge" ] + branches: [ "main", "*-stable", "release/*", "*-merge", "review/**", "saadnajmi/0-85-redbox2-merge" ] concurrency: # Ensure single build of a pull request. `main` should not be affected. @@ -59,9 +59,14 @@ jobs: run: yarn changeset status || true - name: Build packages run: yarn build + - name: Build local codegen + run: yarn workspace @react-native/codegen build + # Generate package types without rewriting the checked-in API snapshot. + - name: Build and validate generated types + run: yarn build-types --validate - name: Simulate publish (dry run) run: | - yarn workspaces foreach -vv --all --topological --no-private npm publish --tag dry-run --tolerate-republish --dry-run + yarn workspaces foreach -vv --all --topological --no-private --include react-native-macos --include '@react-native-macos/*' npm publish --tag dry-run --tolerate-republish --dry-run check-changesets: name: "Check for Changesets" @@ -84,6 +89,26 @@ jobs: run: yarn install --immutable - name: Validate changesets run: yarn change:check + + changeset-config: + name: "Validate Changesets Config" + permissions: {} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 0 + - name: Setup toolchain + uses: ./.github/actions/microsoft-setup-toolchain + with: + node-version: '22' + - name: Install dependencies + run: yarn install --immutable + - name: Validate Changesets config + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: yarn changeset:config:check yarn-constraints: name: "Check Yarn Constraints" @@ -123,6 +148,9 @@ jobs: - name: Run Jest tests run: yarn test-ci + + - name: Test CI release helpers + run: node --test .github/scripts/__tests__/*.test.mjs - name: Run Flow type checker run: yarn flow-check @@ -143,15 +171,44 @@ jobs: permissions: {} uses: ./.github/workflows/microsoft-prebuild-macos-core.yml + # react-native-macos-init resolves a registry package, so its integration test + # requires a published release in the target minor. Registry errors must fail + # this check rather than silently bypass the integration test. + check-release-published: + name: "Check release published" + permissions: {} + if: ${{ endsWith(github.base_ref, '-stable') }} + runs-on: ubuntu-latest + outputs: + published: ${{ steps.check.outputs.published }} + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Export versions + id: versions + run: node .github/scripts/export-versions.mts + - name: Determine if the target react-native-macos minor is published + id: check + env: + REACT_NATIVE_MINOR: ${{ steps.versions.outputs.react_native_version }} + run: | + node .github/scripts/check-release-published.mjs "$REACT_NATIVE_MINOR" + test-react-native-macos-init: name: "Test react-native-macos init" permissions: {} - if: ${{ endsWith(github.base_ref, '-stable') }} + needs: check-release-published + if: ${{ endsWith(github.base_ref, '-stable') && needs.check-release-published.outputs.published == 'true' }} uses: ./.github/workflows/microsoft-test-react-native-macos-init.yml react-native-test-app-integration: name: "Test react-native-test-app integration" permissions: {} + # This workflow supplies local tarballs, so publication is not its prerequisite. if: ${{ endsWith(github.base_ref, '-stable') }} uses: ./.github/workflows/microsoft-react-native-test-app-integration.yml @@ -164,10 +221,12 @@ jobs: - lint-title - npm-publish-dry-run - check-changesets + - changeset-config - yarn-constraints - javascript-tests - build-rntester - prebuild-macos-core + - check-release-published - test-react-native-macos-init - react-native-test-app-integration steps: diff --git a/.github/workflows/microsoft-react-native-test-app-integration.yml b/.github/workflows/microsoft-react-native-test-app-integration.yml index 7c85f8c86935..d81d889453c8 100644 --- a/.github/workflows/microsoft-react-native-test-app-integration.yml +++ b/.github/workflows/microsoft-react-native-test-app-integration.yml @@ -39,32 +39,39 @@ jobs: run: node .github/scripts/export-versions.mts - name: Pack local react-native-macos - working-directory: packages/react-native run: | set -eox pipefail - yarn pack -o ${{ runner.temp }}/react-native-macos.tgz + # The @react-native-macos/virtualized-lists workspace dependency is not + # published at the 1000.0.0 dev version, so pack it too and override it below. + (cd packages/react-native && yarn pack -o ${{ runner.temp }}/react-native-macos.tgz) + (cd packages/virtualized-lists && yarn pack -o ${{ runner.temp }}/virtualized-lists.tgz) - name: Clone react-native-test-app run: | git clone --filter=blob:none --progress https://github.com/microsoft/react-native-test-app.git + git -C react-native-test-app rev-parse HEAD - name: Configure react-native-test-app dependencies working-directory: react-native-test-app/packages/example-macos run: | node ../app/scripts/internal/set-react-version.mts ${{ steps.versions.outputs.react_native_version }} --overrides '{ "react-native-macos": "file:${{ runner.temp }}/react-native-macos.tgz" }' - - name: Pin @types/react to avoid duplicate react-native-macos + - name: Pin dependencies to avoid resolution conflicts working-directory: react-native-test-app run: | - # The test app tree carries both @types/react 19.1.x (example-macos) - # and 19.2.x (app), both satisfying react-native-macos's peer. Under - # Yarn's pnpm nodeLinker this virtualizes react-native-macos twice and - # trips the metro duplicate-dependency checker. Pin to a single version. + # 1. The test app tree carries both @types/react 19.1.x (example-macos) + # and 19.2.x (app), both satisfying react-native-macos's peer. Under + # Yarn's pnpm nodeLinker this virtualizes react-native-macos twice and + # trips the metro duplicate-dependency checker. Pin to a single version. + # 2. The packed react-native-macos depends on the unpublished + # @react-native-macos/virtualized-lists@1000.0.0; --overrides only covers + # the direct react-native-macos dep, so force the transitive one here. node -e " const fs = require('fs'); const root = JSON.parse(fs.readFileSync('package.json', 'utf8')); root.resolutions = root.resolutions || {}; root.resolutions['@types/react'] = '~19.1.0'; + root.resolutions['@react-native-macos/virtualized-lists'] = 'file:${{ runner.temp }}/virtualized-lists.tgz'; fs.writeFileSync('package.json', JSON.stringify(root, null, 2) + '\n'); " diff --git a/.github/workflows/microsoft-test-react-native-macos-init.yml b/.github/workflows/microsoft-test-react-native-macos-init.yml index e9cfd19d0fe4..f58a88a468e9 100644 --- a/.github/workflows/microsoft-test-react-native-macos-init.yml +++ b/.github/workflows/microsoft-test-react-native-macos-init.yml @@ -51,17 +51,19 @@ jobs: working-directory: ${{ runner.temp }} - name: Pack local react-native-macos - working-directory: packages/react-native run: | set -eox pipefail - # Use a tarball instead of a direct path to avoid symlinks - yarn pack -o ${{ runner.temp }}/react-native-macos.tgz + # Use tarballs instead of direct paths to avoid symlinks. The + # @react-native-macos/virtualized-lists workspace dependency is not + # published at the 1000.0.0 dev version, so pack and install it too. + (cd packages/react-native && yarn pack -o ${{ runner.temp }}/react-native-macos.tgz) + (cd packages/virtualized-lists && yarn pack -o ${{ runner.temp }}/virtualized-lists.tgz) - name: Install local react-native-macos working-directory: ${{ runner.temp }}/testcli run: | set -eox pipefail - npm install ${{ runner.temp }}/react-native-macos.tgz + npm install ${{ runner.temp }}/virtualized-lists.tgz ${{ runner.temp }}/react-native-macos.tgz - name: Apply macOS template working-directory: ${{ runner.temp }}/testcli diff --git a/.github/workflows/needs-attention.yml b/.github/workflows/needs-attention.yml index b273b483aa54..4d7a0cf8f02f 100644 --- a/.github/workflows/needs-attention.yml +++ b/.github/workflows/needs-attention.yml @@ -26,4 +26,3 @@ jobs: id: needs-attention - name: Result run: echo '${{ steps.needs-attention.outputs.result }}' - continue-on-error: true # [macOS] diff --git a/.yarnrc.yml b/.yarnrc.yml index 2fb7124f02d3..3fc29bf50556 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -13,9 +13,6 @@ yarnPath: .yarn/releases/yarn-4.12.0.cjs # packageExtensions, so no @react-native/* deps belong here (they must stay in a # manifest to remain under release-branch version-pinning). packageExtensions: - "@react-native/codegen@*": - dependencies: - "@babel/parser": "^7.25.2" "@react-native/babel-plugin-codegen@*": dependencies: "@babel/plugin-syntax-flow": "^7.25.0" diff --git a/package.json b/package.json index 77f4780a4d1d..f7e8cbeab6fb 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "update-lock": "npx yarn-deduplicate", "change": "node .github/scripts/change.mts", "change:check": "node .github/scripts/change.mts --check", + "changeset:config:check": "node .github/scripts/validate-changeset-config.mjs", "changeset": "changeset", "changeset:version": "node .github/scripts/changeset-version-with-postbump.mts" }, @@ -66,6 +67,7 @@ "@babel/traverse": "^7.25.2", "@babel/types": "^7.25.2", "@changesets/cli": "^2.28.1", + "@changesets/get-release-plan": "^4.0.14", "@electron/packager": "^18.3.6", "@expo/spawn-async": "^1.7.2", "@jest/create-cache-key-function": "^29.7.0", @@ -138,6 +140,7 @@ "react": "19.2.0", "react-test-renderer": "19.2.0", "rimraf": "^3.0.2", + "semver": "^7.1.3", "shelljs": "^0.8.5", "signedsource": "^2.0.0", "supports-color": "^7.1.0", diff --git a/packages/react-native-codegen/scripts/build.js b/packages/react-native-codegen/scripts/build.js index c8a5c6350f14..fb221fe14e88 100644 --- a/packages/react-native-codegen/scripts/build.js +++ b/packages/react-native-codegen/scripts/build.js @@ -60,17 +60,18 @@ function getBuildPath(file, buildFolder) { async function buildFile(file, silent) { const destPath = getBuildPath(file, BUILD_DIR); + const relativeFile = path.relative(PACKAGE_DIR, file); fs.mkdirSync(path.dirname(destPath), {recursive: true}); - if (micromatch.isMatch(file, IGNORE_PATTERN)) { + if (micromatch.isMatch(relativeFile, IGNORE_PATTERN)) { silent || process.stdout.write( styleText('dim', ' \u2022 ') + path.relative(PACKAGE_DIR, file) + ' (ignore)\n', ); - } else if (!micromatch.isMatch(file, JS_FILES_PATTERN)) { + } else if (!micromatch.isMatch(relativeFile, JS_FILES_PATTERN)) { fs.createReadStream(file).pipe(fs.createWriteStream(destPath)); silent || process.stdout.write( diff --git a/packages/react-native/Libraries/Components/Button.js b/packages/react-native/Libraries/Components/Button.js index 0b838f8f8d34..6a52a8d570e2 100644 --- a/packages/react-native/Libraries/Components/Button.js +++ b/packages/react-native/Libraries/Components/Button.js @@ -153,11 +153,6 @@ export type ButtonProps = $ReadOnly<{ */ accessibilityRole?: ?AccessibilityRole, - /** - * Accessibility action handlers - */ - onAccessibilityAction?: ?(event: AccessibilityActionEvent) => mixed, - /** * Handler to be called when the button receives key focus */ diff --git a/packages/react-native/Libraries/DevToolsSettings/DevToolsSettingsManager.macos.js b/packages/react-native/Libraries/DevToolsSettings/DevToolsSettingsManager.macos.js deleted file mode 100644 index 96bbe349a329..000000000000 --- a/packages/react-native/Libraries/DevToolsSettings/DevToolsSettingsManager.macos.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -// [macOS] - -// $FlowFixMe[prop-missing] Share the iOS file -export {DevToolsSettingsManager} from './DevToolsSettingsManager.ios'; diff --git a/packages/react-native/Libraries/Image/React-RCTImage.podspec b/packages/react-native/Libraries/Image/React-RCTImage.podspec index 6e3bc6a9389e..5bbcea9aac8d 100644 --- a/packages/react-native/Libraries/Image/React-RCTImage.podspec +++ b/packages/react-native/Libraries/Image/React-RCTImage.podspec @@ -55,6 +55,7 @@ Pod::Spec.new do |s| add_dependency(s, "React-RCTFBReactNativeSpec") add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"]) add_dependency(s, "React-NativeModulesApple") + add_dependency(s, "React-RCTUIKit", :framework_name => 'RCTUIKit') # [macOS] add_rn_third_party_dependencies(s) add_rncore_dependency(s) diff --git a/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec b/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec index 81a9e7d7f189..1ce902e94332 100644 --- a/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec +++ b/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec @@ -52,6 +52,7 @@ Pod::Spec.new do |s| add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"]) add_dependency(s, "React-NativeModulesApple") add_dependency(s, "React-featureflags") + add_dependency(s, "React-RCTUIKit", :framework_name => 'RCTUIKit') # [macOS] add_rn_third_party_dependencies(s) add_rncore_dependency(s) diff --git a/packages/react-native/Libraries/Text/React-RCTText.podspec b/packages/react-native/Libraries/Text/React-RCTText.podspec index b850acf0f414..291ea48191ab 100644 --- a/packages/react-native/Libraries/Text/React-RCTText.podspec +++ b/packages/react-native/Libraries/Text/React-RCTText.podspec @@ -39,4 +39,5 @@ Pod::Spec.new do |s| s.dependency "Yoga" s.dependency "React-Core/RCTTextHeaders", version + add_dependency(s, "React-RCTUIKit", :framework_name => 'RCTUIKit', :version => version) # [macOS] end diff --git a/packages/react-native/Libraries/Types/CodegenTypesNamespace.d.ts b/packages/react-native/Libraries/Types/CodegenTypesNamespace.d.ts index 1727e69eae2f..79b7b50478d3 100644 --- a/packages/react-native/Libraries/Types/CodegenTypesNamespace.d.ts +++ b/packages/react-native/Libraries/Types/CodegenTypesNamespace.d.ts @@ -7,8 +7,7 @@ * @format */ -import type {NativeSyntheticEvent} from 'react-native'; -import type {EventSubscription} from 'react-native/Libraries/vendor/emitter/EventEmitter'; +import type {EventSubscription, NativeSyntheticEvent} from 'react-native'; // Event types // We're not using the PaperName, it is only used to codegen view config settings diff --git a/packages/react-native/Libraries/Types/CoreEventTypes.d.ts b/packages/react-native/Libraries/Types/CoreEventTypes.d.ts index 504b26603022..a9ff474f530e 100644 --- a/packages/react-native/Libraries/Types/CoreEventTypes.d.ts +++ b/packages/react-native/Libraries/Types/CoreEventTypes.d.ts @@ -250,7 +250,7 @@ export interface TargetedEvent { export type BlurEvent = NativeSyntheticEvent; -export type FocusEvent = NativeSyntheticEvent; +export interface FocusEvent extends NativeSyntheticEvent {} // [macOS] Preserve the public native payload interface. export interface PointerEvents { onPointerEnter?: ((event: PointerEvent) => void) | undefined; @@ -311,8 +311,6 @@ export interface NativeFocusEvent extends TargetedEvent {} export interface NativeBlurEvent extends TargetedEvent {} -export interface FocusEvent extends NativeSyntheticEvent {} - export interface BlueEvent extends NativeSyntheticEvent {} // Drag and Drop types diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index dacb6f8beeef..a6958a8a7889 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -421,15 +421,10 @@ let reactCore = RNTarget( ) /// React-Fabric.podspec -// [macOS -#if os(macOS) -let reactFabricViewPlatformSources = ["components/view/platform/macos"] -let reactFabricViewPlatformExcludes = ["components/view/platform/cxx"] -#else -let reactFabricViewPlatformSources = ["components/view/platform/cxx"] -let reactFabricViewPlatformExcludes = ["components/view/platform/macos"] -#endif -// macOS] +// [macOS] Compile the guarded macOS implementations through components/view. +// The implementations use TargetConditionals for the build destination. +// Do not add a platform directory to sources: RNTarget also adds it to the header +// search paths, bypassing those dispatch headers when cross-compiling. let reactFabric = RNTarget( name: .reactFabric, path: "ReactCommon/react/renderer", @@ -465,9 +460,9 @@ let reactFabric = RNTarget( "components/virtualview", "components/virtualviewexperimental", "components/root/tests", - ] + reactFabricViewPlatformExcludes, // [macOS] + ], dependencies: [.reactNativeDependencies, .reactJsiExecutor, .rctTypesafety, .reactTurboModuleCore, .jsi, .logger, .reactDebug, .reactFeatureFlags, .reactUtils, .reactRuntimeScheduler, .reactCxxReact, .reactRendererDebug, .reactGraphics, .yoga], - sources: ["animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/scrollview", "components/scrollview/platform/cxx", "components/legacyviewmanagerinterop", "dom", "scheduler", "mounting", "observers/events", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency"] + reactFabricViewPlatformSources // [macOS] + sources: ["animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/scrollview", "components/scrollview/platform/cxx", "components/legacyviewmanagerinterop", "dom", "scheduler", "mounting", "observers/events", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency"] ) let reactFabricInputAccessory = RNTarget( @@ -933,7 +928,12 @@ extension Target { let numOfSlash = path.count { $0 == "/" } let cxxCommonHeaderPaths: [CXXSetting] = - Set(searchPaths).map { + // [macOS] Select headers by destination, before the shared/generated paths. + // SwiftPM evaluates manifest #if os(...) on the host, not the destination. + [ + CXXSetting.headerSearchPath(relativeSearchPath(numOfSlash + 1, "ReactCommon/react/renderer/components/view/platform/macos"), .when(platforms: [.macOS])), + CXXSetting.headerSearchPath(relativeSearchPath(numOfSlash + 1, "ReactCommon/react/renderer/components/view/platform/cxx"), .when(platforms: [.iOS, .visionOS, .macCatalyst])), + ] + Set(searchPaths).map { CXXSetting.headerSearchPath(relativeSearchPath(numOfSlash + 1, $0)) } + [ CXXSetting.headerSearchPath(relativeSearchPath(numOfSlash + 1, ".build/headers")), diff --git a/packages/react-native/React/Base/RCTTouchHandler.m b/packages/react-native/React/Base/RCTTouchHandler.m index b85ee3dd203f..e97f96c3b59d 100644 --- a/packages/react-native/React/Base/RCTTouchHandler.m +++ b/packages/react-native/React/Base/RCTTouchHandler.m @@ -10,7 +10,6 @@ #if !TARGET_OS_OSX // [macOS] #import #endif // [macOS] -#import // [macOS] #import "RCTAssert.h" #import "RCTBridge.h" @@ -144,8 +143,8 @@ - (void)_recordNewTouches:(NSSet *)touches } else if ([targetView isKindOfClass:[NSText class]]) { _shouldSendMouseUpOnSystemBehalf = [(NSText*)targetView isSelectable]; } - else if ([targetView.superview isKindOfClass:[RCTUITextField class]]) { - _shouldSendMouseUpOnSystemBehalf = [(RCTUITextField*)targetView.superview isSelectable]; + else if ([targetView.superview isKindOfClass:[NSTextField class]]) { + _shouldSendMouseUpOnSystemBehalf = [(NSTextField*)targetView.superview isSelectable]; } else { _shouldSendMouseUpOnSystemBehalf = NO; } diff --git a/packages/react-native/React/CoreModules/RCTRedBox.mm b/packages/react-native/React/CoreModules/RCTRedBox.mm index 35da92633158..19922d2aba50 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox.mm @@ -371,6 +371,10 @@ - (void)showErrorMessage:(NSString *)message _lastErrorMessage = [messageWithoutAnsi substringToIndex:MIN((NSUInteger)10000, messageWithoutAnsi.length)]; _lastErrorCookie = errorCookie; +#if TARGET_OS_OSX // [macOS + // Create the table before reloading it on the first presentation. + (void)self.view; +#endif // macOS] [_stackTraceTableView reloadData]; if (!isRootViewControllerPresented) { @@ -394,7 +398,7 @@ - (void)dismiss [self dismissViewControllerAnimated:YES completion:nil]; #else // [macOS] if (self.presentingViewController) { - [[RCTKeyWindow() contentViewController] dismissViewController:self]; + [self.presentingViewController dismissViewController:self]; } #endif // macOS] } @@ -597,6 +601,9 @@ - (RCTUITableViewCell *)reuseCell:(RCTUITableViewCell *)cell forStackFrame:(RCTJ - (CGFloat)tableView:(RCTUITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath // [macOS] { +#if TARGET_OS_OSX // [macOS + return RCTUITableViewAutomaticDimension; +#else // macOS] if (indexPath.section == 0) { NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; paragraphStyle.lineBreakMode = NSLineBreakByWordWrapping; @@ -618,6 +625,7 @@ - (CGFloat)tableView:(RCTUITableView *)tableView heightForRowAtIndexPath:(NSInde } else { return 50; } +#endif // [macOS] } - (void)tableView:(RCTUITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath // [macOS] diff --git a/packages/react-native/React/CoreModules/React-CoreModules.podspec b/packages/react-native/React/CoreModules/React-CoreModules.podspec index 59906723b425..bfd47144d437 100644 --- a/packages/react-native/React/CoreModules/React-CoreModules.podspec +++ b/packages/react-native/React/CoreModules/React-CoreModules.podspec @@ -65,6 +65,7 @@ Pod::Spec.new do |s| add_dependency(s, "React-RCTFBReactNativeSpec") add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"]) add_dependency(s, "React-NativeModulesApple") + add_dependency(s, "React-RCTUIKit", :framework_name => 'RCTUIKit', :version => version) # [macOS] add_rn_third_party_dependencies(s) add_rncore_dependency(s) diff --git a/packages/react-native/React/React-RCTFabric.podspec b/packages/react-native/React/React-RCTFabric.podspec index a034eaa21b16..70e9d352ca52 100644 --- a/packages/react-native/React/React-RCTFabric.podspec +++ b/packages/react-native/React/React-RCTFabric.podspec @@ -52,6 +52,7 @@ Pod::Spec.new do |s| # [macOS MobileCoreServices not available on macOS s.ios.framework = "MobileCoreServices" s.visionos.framework = "MobileCoreServices" + s.osx.frameworks = ["UniformTypeIdentifiers"] # macOS] s.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => header_search_paths, @@ -93,6 +94,7 @@ Pod::Spec.new do |s| add_dependency(s, "React-runtimeexecutor", :additional_framework_paths => ["platform/ios"]) add_dependency(s, "React-runtimescheduler") add_dependency(s, "React-RCTAnimation", :framework_name => 'RCTAnimation') + add_dependency(s, "React-RCTUIKit", :framework_name => 'RCTUIKit') # [macOS] add_dependency(s, "React-jsinspector", :framework_name => 'jsinspector_modern') add_dependency(s, "React-jsinspectorcdp", :framework_name => 'jsinspector_moderncdp') add_dependency(s, "React-jsinspectortracing", :framework_name => 'jsinspector_moderntracing') diff --git a/packages/react-native/React/Views/RCTView.m b/packages/react-native/React/Views/RCTView.m index e21035af2c47..7b009661bb82 100644 --- a/packages/react-native/React/Views/RCTView.m +++ b/packages/react-native/React/Views/RCTView.m @@ -25,9 +25,6 @@ #import "RCTViewUtils.h" #import "UIView+React.h" #import "RCTViewKeyboardEvent.h" -#if TARGET_OS_OSX // [macOS -#import "RCTTextView.h" -#endif // macOS] RCT_MOCK_DEF(RCTView, RCTContentInsets); #define RCTContentInsets RCT_MOCK_USE(RCTView, RCTContentInsets) @@ -112,7 +109,8 @@ - (RCTPlatformView *)react_findClipView // [macOS] NSString *label = subview.accessibilityLabel; #else // [macOS NSString *label; - if ([subview isKindOfClass:[RCTTextView class]]) { + // React-RCTText depends on React-Core, so resolve its optional class without a link dependency. + if ([subview isKindOfClass:NSClassFromString(@"RCTTextView")]) { // on macOS VoiceOver a text element will always have its accessibilityValue read, but will only read it's accessibilityLabel if it's value is set. // the macOS RCTTextView accessibilityValue will return its accessibilityLabel if set otherwise return its text. label = subview.accessibilityValue; diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m index 1e4f77f99b5c..5a23848cd3d5 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m @@ -17,6 +17,21 @@ static NSString *const RCTUITableViewHeaderHeightConstraintIdentifier = @"RCTUITableViewHeaderHeight"; static char RCTUITableViewHeaderHeightConstraintKey; +static void RCTUITableViewConfigureLabels(NSView *view, BOOL automaticHeight) +{ + if ([view isKindOfClass:[NSTextField class]]) { + // AppKit's column-width constraint has priority 500. Let text wrap within it. + [view setContentCompressionResistancePriority:NSLayoutPriorityDefaultLow + forOrientation:NSLayoutConstraintOrientationHorizontal]; + // Automatic row fitting must include the full height of each visible label. + [view setContentCompressionResistancePriority:automaticHeight ? NSLayoutPriorityRequired : NSLayoutPriorityDefaultHigh + forOrientation:NSLayoutConstraintOrientationVertical]; + } + for (NSView *subview in view.subviews) { + RCTUITableViewConfigureLabels(subview, automaticHeight); + } +} + typedef NS_ENUM(NSInteger, RCTUITableViewSlotKind) { RCTUITableViewSlotKindHeader, RCTUITableViewSlotKindRow, @@ -186,6 +201,7 @@ - (void)setFrameSize:(NSSize)newSize - (void)setFixedHeight:(NSNumber *)fixedHeight { + RCTUITableViewConfigureLabels(self.contentView, fixedHeight == nil); if (fixedHeight == nil) { _fixedHeightConstraint.active = NO; _fixedHeightConstraint = nil; @@ -240,6 +256,8 @@ - (instancetype)initWithFrame:(NSRect)frameRect [_tableView addTableColumn:column]; self.documentView = _tableView; + // The column retains its default width until it belongs to a scroll view. + [_tableView sizeLastColumnToFit]; _lastContentWidth = self.contentSize.width; self.separatorColor = nil; } @@ -276,6 +294,7 @@ - (void)setFrameSize:(NSSize)newSize CGFloat contentWidth = self.contentSize.width; if (_lastContentWidth != contentWidth) { _lastContentWidth = contentWidth; + [_tableView sizeLastColumnToFit]; if (_automaticRows.count > 0) { [_tableView noteHeightOfRowsWithIndexesChanged:_automaticRows]; } diff --git a/packages/react-native/ReactCommon/React-Fabric.podspec b/packages/react-native/ReactCommon/React-Fabric.podspec index 6ab41bcf4134..dbc6bd510ee6 100644 --- a/packages/react-native/ReactCommon/React-Fabric.podspec +++ b/packages/react-native/ReactCommon/React-Fabric.podspec @@ -132,6 +132,14 @@ Pod::Spec.new do |s| sss.source_files = "react/renderer/components/view/**/*.{m,mm,cpp,h}" # [macOS] sss.exclude_files = "react/renderer/components/view/tests", "react/renderer/components/view/platform/android", "react/renderer/components/view/platform/windows" # [macOS] sss.header_dir = "react/renderer/components/view" + # [macOS Keep the canonical wrappers and their physical headers in separate namespaces. + # The view sources also remain present with prebuilt RNCore, where the root mapping is not set. + sss.header_mappings_dir = ENV['USE_FRAMEWORKS'] ? "./" : "react/renderer/components/view" + sss.osx.exclude_files = "react/renderer/components/view/platform/cxx/**/*.h" + sss.ios.exclude_files = "react/renderer/components/view/platform/macos/**/HostPlatform*.h" + sss.tvos.exclude_files = "react/renderer/components/view/platform/macos/**/HostPlatform*.h" + sss.visionos.exclude_files = "react/renderer/components/view/platform/macos/**/HostPlatform*.h" + # macOS] end ss.subspec "scrollview" do |sss| diff --git a/packages/react-native/ReactCommon/ReactCommon.podspec b/packages/react-native/ReactCommon/ReactCommon.podspec index 83e81864640d..ea00e7bb4476 100644 --- a/packages/react-native/ReactCommon/ReactCommon.podspec +++ b/packages/react-native/ReactCommon/ReactCommon.podspec @@ -63,10 +63,12 @@ Pod::Spec.new do |s| ss.subspec "core" do |sss| sss.source_files = podspec_sources("react/nativemodule/core/ReactCommon/**/*.{cpp,h}", "react/nativemodule/core/ReactCommon/**/*.h") - sss.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ReactCommon\" \"$(PODS_CONFIGURATION_BUILD_DIR)/React-debug/React_debug.framework/Headers\" \"$(PODS_CONFIGURATION_BUILD_DIR)/React-debug/React_featureflags.framework/Headers\" \"$(PODS_CONFIGURATION_BUILD_DIR)/React-utils/React_utils.framework/Headers\"" } - sss.dependency "React-debug", version - sss.dependency "React-featureflags", version - sss.dependency "React-utils", version + # [macOS + sss.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ReactCommon\"" } + add_dependency(sss, "React-debug", :version => version) + add_dependency(sss, "React-featureflags", :version => version) + add_dependency(sss, "React-utils", :version => version) + # macOS] end end end diff --git a/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec b/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec index d6282664acff..28a84374fdda 100644 --- a/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec +++ b/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec @@ -29,7 +29,6 @@ Pod::Spec.new do |s| s.source = source s.source_files = podspec_sources("*.{cpp,h}", "*.h") s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"$(PODS_CONFIGURATION_BUILD_DIR)/React-debug/React_debug.framework/Headers\" \"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers\"", "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard() } s.header_dir = "cxxreact" @@ -42,7 +41,7 @@ Pod::Spec.new do |s| s.dependency "React-perflogger", version s.dependency "React-jsi", version s.dependency "React-logger", version - s.dependency "React-debug", version + add_dependency(s, "React-debug", :version => version) # [macOS] s.dependency "React-timing", version s.resource_bundles = {'React-cxxreact_privacy' => 'PrivacyInfo.xcprivacy'} diff --git a/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec b/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec index ffed0e5ae2ab..cd80dd7e7a58 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec +++ b/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec @@ -37,6 +37,7 @@ Pod::Spec.new do |s| "USE_HEADERMAP" => "YES", "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), "GCC_WARN_PEDANTIC" => "YES" } + s.frameworks = "CoreGraphics" # [macOS] # [macOS Restrict UIKit to iOS and visionOS s.ios.framework = "UIKit" s.visionos.framework = "UIKit" diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformTouch.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformTouch.h new file mode 100644 index 000000000000..dacb710314b8 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformTouch.h @@ -0,0 +1,22 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// [macOS] + +#pragma once + +#if defined(__APPLE__) +#include +#endif + +#if defined(__ANDROID__) +#include "platform/android/react/renderer/components/view/HostPlatformTouch.h" +#elif defined(__APPLE__) && TARGET_OS_OSX +#include "platform/macos/react/renderer/components/view/HostPlatformTouch.h" +#else +#include "platform/cxx/react/renderer/components/view/HostPlatformTouch.h" +#endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewEventEmitter.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewEventEmitter.h new file mode 100644 index 000000000000..83ac40eeffae --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewEventEmitter.h @@ -0,0 +1,22 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// [macOS] + +#pragma once + +#if defined(__APPLE__) +#include +#endif + +#if defined(__ANDROID__) +#include "platform/android/react/renderer/components/view/HostPlatformViewEventEmitter.h" +#elif defined(__APPLE__) && TARGET_OS_OSX +#include "platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.h" +#else +#include "platform/cxx/react/renderer/components/view/HostPlatformViewEventEmitter.h" +#endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewProps.h new file mode 100644 index 000000000000..9dca34cea540 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewProps.h @@ -0,0 +1,22 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// [macOS] + +#pragma once + +#if defined(__APPLE__) +#include +#endif + +#if defined(__ANDROID__) +#include "platform/android/react/renderer/components/view/HostPlatformViewProps.h" +#elif defined(__APPLE__) && TARGET_OS_OSX +#include "platform/macos/react/renderer/components/view/HostPlatformViewProps.h" +#else +#include "platform/cxx/react/renderer/components/view/HostPlatformViewProps.h" +#endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewTraitsInitializer.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewTraitsInitializer.h new file mode 100644 index 000000000000..321959c54c11 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewTraitsInitializer.h @@ -0,0 +1,22 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// [macOS] + +#pragma once + +#if defined(__APPLE__) +#include +#endif + +#if defined(__ANDROID__) +#include "platform/android/react/renderer/components/view/HostPlatformViewTraitsInitializer.h" +#elif defined(__APPLE__) && TARGET_OS_OSX +#include "platform/macos/react/renderer/components/view/HostPlatformViewTraitsInitializer.h" +#else +#include "platform/cxx/react/renderer/components/view/HostPlatformViewTraitsInitializer.h" +#endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/KeyEvent.h b/packages/react-native/ReactCommon/react/renderer/components/view/KeyEvent.h new file mode 100644 index 000000000000..4514fc55c453 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/KeyEvent.h @@ -0,0 +1,12 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// [macOS] + +#pragma once + +#include "platform/macos/react/renderer/components/view/KeyEvent.h" diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/MouseEvent.h b/packages/react-native/ReactCommon/react/renderer/components/view/MouseEvent.h new file mode 100644 index 000000000000..cffdc4bec6bd --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/MouseEvent.h @@ -0,0 +1,12 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// [macOS] + +#pragma once + +#include "platform/macos/react/renderer/components/view/MouseEvent.h" diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp index 5b8b18d67be7..8e0d0d725fd6 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp @@ -5,7 +5,11 @@ * LICENSE file in the root directory of this source tree. */ - // [macOS] +#if defined(__APPLE__) +#include +#endif + +#if defined(__APPLE__) && TARGET_OS_OSX // [macOS #include #include @@ -211,3 +215,5 @@ void HostPlatformViewEventEmitter::onDrop(const DragEvent& dragEvent) const { } } // namespace facebook::react + +#endif // macOS] diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp index 4190d7a57e13..b0d1ac80423c 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp @@ -5,6 +5,12 @@ * LICENSE file in the root directory of this source tree. */ +#if defined(__APPLE__) +#include +#endif + +#if defined(__APPLE__) && TARGET_OS_OSX // [macOS + #include "HostPlatformViewProps.h" #include @@ -160,3 +166,5 @@ void HostPlatformViewProps::setProp( } // namespace facebook::react + +#endif // macOS] diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec b/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec index 489b1d916bea..320b86ec963d 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec +++ b/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec @@ -32,6 +32,7 @@ Pod::Spec.new do |s| s.source = source s.source_files = podspec_sources(source_files, ["*.h", "platform/ios/**/*.h"]) s.header_dir = "react/renderer/graphics" + s.frameworks = "CoreGraphics" # [macOS] # [macOS Restrict UIKit to iOS and visionOS s.ios.framework = "UIKit" s.visionos.framework = "UIKit" diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm index a01024a20646..5f4512707749 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -#import "HostPlatformColor.h" +#import // [macOS] #import #import // [macOS] diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm index 93c3a33a9cec..d3e7f18f1439 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm @@ -385,7 +385,7 @@ static UIFontDescriptorSystemDesign RCTGetFontDescriptorSystemDesign(NSString *f #else // [macOS fontNames = RCTFontNamesForFamilyName(font.familyName); #endif // macOS] - fontWeight = (fontWeight != 0.0) ?: RCTGetFontWeight(font); + fontWeight = (fontWeight != 0.0) ? fontWeight : RCTGetFontWeight(font); } else { // Failback to system font. font = RCTDefaultFontWithFontProperties(fontProperties); diff --git a/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec b/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec index 7c84db0bca95..9013ce9dfcff 100644 --- a/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec +++ b/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec @@ -49,12 +49,12 @@ Pod::Spec.new do |s| s.dependency "React-Core/Default" s.dependency "React-CoreModules" s.dependency "React-NativeModulesApple" - s.dependency "React-RCTFabric" + add_dependency(s, "React-RCTFabric", :framework_name => "RCTFabric") # [macOS] s.dependency "React-RuntimeCore" s.dependency "React-Mapbuffer" s.dependency "React-jserrorhandler" s.dependency "React-jsinspector" - s.dependency "React-featureflags" + add_dependency(s, "React-featureflags") # [macOS] add_dependency(s, "React-jsitooling", :framework_name => "JSITooling") add_dependency(s, "React-RCTFBReactNativeSpec") add_dependency(s, "React-utils", :additional_framework_paths => ["react/utils/platform/ios"]) diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 796a00ed71f6..d81f44d1829e 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<4e0d1f9ebc86fb237989b7099bf6f9df>> + * @generated SignedSource<<38cf603123a1b85c7517d48f502218be>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -21,7 +21,15 @@ /* eslint-disable redundant-undefined/redundant-undefined */ +import type { FocusEvent as FocusEvent_2 } from "react-native" +import type { LayoutChangeEvent as LayoutChangeEvent_2 } from "react-native" +import type { LayoutRectangle as LayoutRectangle_2 } from "react-native" import * as React from "react" +import type { ScrollResponderType as ScrollResponderType_2 } from "react-native" +import { ScrollView as ScrollView_2 } from "react-native" +import type { ScrollViewProps as ScrollViewProps_2 } from "react-native" +import type { StyleProp as StyleProp_2 } from "react-native" +import type { ViewStyle as ViewStyle_2 } from "react-native" declare const $$AndroidSwitchNativeComponent: NativeType declare const $$AnimatedFlatList: ( props: Omit>, "ref"> & { @@ -96,6 +104,7 @@ declare const absoluteFill: AbsoluteFillStyle declare const absoluteFillObject: AbsoluteFillStyle declare const AccessibilityInfo: typeof AccessibilityInfo_default declare const AccessibilityInfo_default: { + isHighContrastEnabled: () => Promise addEventListener( eventName: K, handler: (...$$REST$$: AccessibilityEventDefinitions[K]) => void, @@ -171,6 +180,10 @@ declare const Clipboard: { } declare const codegenNativeCommands: typeof codegenNativeCommands_default declare const codegenNativeComponent: typeof codegenNativeComponent_default +declare const ColorWithSystemEffectMacOS: ( + color: ColorValue, + effect: SystemEffectMacOS, +) => ColorValue declare const compose: typeof composeStyles_default declare const create: ( obj: S & ____Styles_Internal, @@ -209,6 +222,7 @@ declare const divideImpl: ( ) => AnimatedDivision_default declare const DrawerLayoutAndroid: typeof DrawerLayoutAndroid_default declare const DynamicColorIOS: (tuple: DynamicColorIOSTuple) => ColorValue +declare const DynamicColorMacOS: (tuple: DynamicColorMacOSTuple) => ColorValue declare const Easing: typeof EasingStatic_default declare const EasingStatic_default: { back(s?: number): EasingFunction @@ -833,6 +847,7 @@ declare type ____TextStyle_Internal = Readonly< > declare type ____TextStyle_InternalBase = { readonly color?: ____ColorValue_Internal + readonly cursor?: CursorValue readonly fontFamily?: string readonly fontSize?: number readonly fontStyle?: "italic" | "normal" @@ -997,6 +1012,7 @@ declare class _TextInputInstance extends ReactNativeElement_default { clear(): void getNativeRef(): ReactNativeElement_default | undefined isFocused(): boolean + setGhostText(ghostText: string | undefined): void setSelection(start: number, end: number): void } declare type $$AndroidSwitchNativeComponent = @@ -1053,7 +1069,8 @@ declare type AccessibilityActionName = | "magicTap" declare type AccessibilityEventDefinitions = AccessibilityEventDefinitionsAndroid & - AccessibilityEventDefinitionsIOS & { + AccessibilityEventDefinitionsIOS & + AccessibilityEventDefinitionsMacOS & { change: [boolean] reduceMotionChanged: [boolean] screenReaderChanged: [boolean] @@ -1075,6 +1092,9 @@ declare type AccessibilityEventDefinitionsIOS = { invertColorsChanged: [boolean] reduceTransparencyChanged: [boolean] } +declare type AccessibilityEventDefinitionsMacOS = { + highContrastChanged: [boolean] +} declare type AccessibilityEventTypes = "click" | "focus" | "viewHoverEnter" declare type AccessibilityInfo = typeof AccessibilityInfo declare type AccessibilityProps = Readonly< @@ -1144,6 +1164,7 @@ declare type AccessibilityRole = | "list" | "menu" | "menubar" + | "menubutton" | "menuitem" | "none" | "pager" @@ -1235,6 +1256,17 @@ declare class Alert { keyboardType?: string, options?: AlertOptions, ): void + static promptMacOS( + title: null | string | undefined, + message?: null | string | undefined, + callbackOrButtons?: + | (((text: string) => void) | null | undefined) + | AlertButtons, + type?: AlertType | null | undefined, + defaultInputs?: DefaultInputsArray, + modal?: boolean | null | undefined, + critical?: boolean | null | undefined, + ): void } declare type AlertButton = { isPreferred?: boolean @@ -1246,6 +1278,8 @@ declare type AlertButtons = Array declare type AlertButtonStyle = "cancel" | "default" | "destructive" declare type AlertOptions = { cancelable?: boolean + critical?: boolean + modal?: boolean onDismiss?: () => void userInterfaceStyle?: "dark" | "light" | "unspecified" } @@ -1764,6 +1798,7 @@ declare type ButtonProps = { readonly accessibilityHint?: string readonly accessibilityLabel?: string readonly accessibilityLanguage?: string + readonly accessibilityRole?: AccessibilityRole readonly accessibilityState?: AccessibilityState readonly accessible?: boolean readonly "aria-busy"?: boolean @@ -1786,8 +1821,11 @@ declare type ButtonProps = { readonly nextFocusRight?: number readonly nextFocusUp?: number readonly onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown + readonly onBlur?: (e: BlurEvent) => void + readonly onFocus?: (e: FocusEvent) => void readonly testID?: string readonly title: string + readonly tooltip?: string readonly touchSoundDisabled?: boolean readonly onPress?: (event?: GestureResponderEvent) => unknown } @@ -1817,9 +1855,9 @@ declare type CellRendererProps = { readonly children: React.ReactNode readonly index: number readonly item: ItemT - readonly style: StyleProp - readonly onFocusCapture?: (event: FocusEvent) => void - readonly onLayout?: (event: LayoutChangeEvent) => void + readonly style: StyleProp_2 + readonly onFocusCapture?: (event: FocusEvent_2) => void + readonly onLayout?: (event: LayoutChangeEvent_2) => void } declare class CellRenderMask { addCells(cells: { first: number; last: number }): void @@ -1866,6 +1904,7 @@ declare namespace CodegenTypes { declare type ColorListenerCallback = (value: ColorValue) => unknown declare type ColorSchemeName = "dark" | "light" | "unspecified" declare type ColorValue = ____ColorValue_Internal +declare type ColorWithSystemEffectMacOS = typeof ColorWithSystemEffectMacOS declare type ComponentProvider = () => React.ComponentType declare type ComponentProviderInstrumentationHook = ( component_: ComponentProvider, @@ -1938,17 +1977,80 @@ declare function createPublicTextInstance( ownerDocument: ReactNativeDocument_default, ): ReadOnlyText_default declare type createPublicTextInstanceT = typeof createPublicTextInstance -declare type CursorValue = "auto" | "pointer" +declare type CursorValue = + | "alias" + | "all-scroll" + | "auto" + | "cell" + | "col-resize" + | "context-menu" + | "copy" + | "crosshair" + | "default" + | "e-resize" + | "ew-resize" + | "grab" + | "grabbing" + | "help" + | "move" + | "n-resize" + | "ne-resize" + | "nesw-resize" + | "no-drop" + | "none" + | "not-allowed" + | "ns-resize" + | "nw-resize" + | "nwse-resize" + | "pointer" + | "progress" + | "row-resize" + | "s-resize" + | "se-resize" + | "sw-resize" + | "text" + | "url" + | "vertical-text" + | "w-resize" + | "wait" + | "zoom-in" + | "zoom-out" declare type DataDetectorTypesType = | "address" | "all" | "calendarEvent" + | "correction" + | "dash" | "flightNumber" + | "grammar" | "link" | "lookupSuggestion" | "none" + | "ortography" | "phoneNumber" + | "quote" + | "regularExpression" + | "replacement" + | "spelling" | "trackingNumber" + | "transitInformation" +declare type DataTransfer = { + readonly files: ReadonlyArray + readonly items: ReadonlyArray + readonly types: ReadonlyArray +} +declare type DataTransferFile = { + readonly height?: number + readonly name: string + readonly size?: number + readonly type: string | undefined + readonly uri: string + readonly width?: number +} +declare type DataTransferItem = { + readonly kind: string + readonly type: string | undefined +} declare type decay = typeof decay declare type DecayAnimationConfig = Readonly< AnimationConfig & { @@ -1962,6 +2064,11 @@ declare type DecayAnimationConfig = Readonly< } > declare type DecelerationRateType = "fast" | "normal" | number +declare type DefaultInputsArray = Array<{ + default?: string + placeholder?: string + style?: AlertButtonStyle +}> declare type DefaultSectionT = { [key: string]: any } @@ -2022,8 +2129,10 @@ declare type DirectEventProps = { readonly onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown readonly onAccessibilityEscape?: () => unknown readonly onAccessibilityTap?: () => unknown + readonly onInvertedDidChange?: () => unknown readonly onLayout?: (event: LayoutChangeEvent) => unknown readonly onMagicTap?: () => unknown + readonly onPreferredScrollerStyleDidChange?: (event: ScrollEvent) => unknown } declare type DisplayMetrics = { fontScale: number @@ -2089,6 +2198,16 @@ declare class DOMRectReadOnly_default { get y(): number } declare type Double = number +declare type DragEvent = NativeSyntheticEvent<{ + readonly clientX: number + readonly clientY: number + readonly dataTransfer?: DataTransfer + readonly pageX: number + readonly pageY: number + readonly timestamp: number +}> +declare type DraggedType = "fileUrl" | "image" | "string" +declare type DraggedTypesType = DraggedType | ReadonlyArray declare type DrawerLayoutAndroid = typeof DrawerLayoutAndroid declare class DrawerLayoutAndroid_default extends React.Component @@ -2157,6 +2276,13 @@ declare type DynamicColorIOSTuple = { highContrastLight?: ColorValue light: ColorValue } +declare type DynamicColorMacOS = typeof DynamicColorMacOS +declare type DynamicColorMacOSTuple = { + dark: ColorValue + highContrastDark?: ColorValue + highContrastLight?: ColorValue + light: ColorValue +} declare type Easing = typeof Easing declare type EasingFunction = (t: number) => number declare type EdgeInsetsOrSizeProp = RectOrSize @@ -2231,6 +2357,8 @@ declare type EventHandlers = { readonly onBlur: (event: BlurEvent) => void readonly onClick: (event: GestureResponderEvent) => void readonly onFocus: (event: FocusEvent) => void + readonly onKeyDown?: (event: KeyEvent) => void + readonly onKeyUp?: (event: KeyEvent) => void readonly onMouseEnter?: (event: MouseEvent) => void readonly onMouseLeave?: (event: MouseEvent) => void readonly onPointerEnter?: (event: PointerEvent) => void @@ -2363,18 +2491,38 @@ declare class FlatList extends React.PureComponent< viewPosition?: number }): void scrollToOffset(params: { animated?: boolean; offset: number }): void + selectRowAtIndex(index: number): void setNativeProps(props: { [$$Key$$: string]: unknown }): void } declare type FlatListBaseProps = RequiredFlatListProps & OptionalFlatListProps declare type FlatListProps = Omit< - VirtualizedListProps, + Omit< + VirtualizedListProps, + | "data" + | "getItem" + | "getItemCount" + | "getItemLayout" + | "keyExtractor" + | "renderItem" + >, + | "columnWrapperStyle" | "data" - | "getItem" - | "getItemCount" + | "enableSelectionOnKeyPress" + | "extraData" + | "fadingEdgeLength" | "getItemLayout" + | "horizontal" + | "initialNumToRender" + | "initialScrollIndex" + | "initialSelectedIndex" + | "inverted" | "keyExtractor" + | "numColumns" + | "removeClippedSubviews" | "renderItem" + | "strictMode" + | never > & FlatListBaseProps declare type flatten = typeof flatten @@ -2481,6 +2629,13 @@ declare function getWithFallback_DEPRECATED( ): React.ComponentType declare type hairlineWidth = typeof hairlineWidth declare type Handle = number +declare type HandledKeyEvent = { + readonly altKey?: boolean + readonly ctrlKey?: boolean + readonly key: string + readonly metaKey?: boolean + readonly shiftKey?: boolean +} declare type Headers = { [name: string]: string } @@ -2632,6 +2787,7 @@ declare type ImagePropsBase = Readonly< | "children" | "onLayout" | "testID" + | "tooltip" > & { accessibilityLabel?: string accessible?: boolean @@ -2664,6 +2820,7 @@ declare type ImagePropsBase = Readonly< srcSet?: string testID?: string tintColor?: ColorValue + tooltip?: string width?: number } > @@ -2892,6 +3049,12 @@ declare type KeyboardEventEasing = | "keyboard" | "linear" declare type KeyboardEventName = keyof KeyboardEventDefinitions +declare type KeyboardEventProps = { + readonly keyDownEvents?: Array + readonly keyUpEvents?: Array + readonly onKeyDown?: (event: KeyEvent) => void + readonly onKeyUp?: (event: KeyEvent) => void +} declare class KeyboardImpl { addListener( eventType: K, @@ -2933,6 +3096,21 @@ declare type KeyboardTypeOptions = | KeyboardType | KeyboardTypeAndroid | KeyboardTypeIOS +declare type KeyEvent = NativeSyntheticEvent<{ + readonly altKey: boolean + readonly ArrowDown: boolean + readonly ArrowLeft: boolean + readonly ArrowRight: boolean + readonly ArrowUp: boolean + readonly capsLockKey: boolean + readonly ctrlKey: boolean + readonly functionKey: boolean + readonly helpKey: boolean + readonly key: string + readonly metaKey: boolean + readonly numericPadKey: boolean + readonly shiftKey: boolean +}> declare function keyExtractor(item: any, index: number): string declare type KeysOfUnion = T extends any ? keyof T : never declare type LayoutAnimation = typeof LayoutAnimation @@ -3022,7 +3200,7 @@ declare class LinkingImpl extends NativeEventEmitter { declare class ListMetricsAggregator_default { cartesianOffset(flowRelativeOffset: number): number flowRelativeOffset( - layout: LayoutRectangle, + layout: LayoutRectangle_2, referenceContentLength?: null | number | undefined, ): number getAverageCellLength(): number @@ -3038,7 +3216,7 @@ declare class ListMetricsAggregator_default { notifyCellLayout($$PARAM_0$$: { cellIndex: number cellKey: string - layout: LayoutRectangle + layout: LayoutRectangle_2 orientation: ListOrientation }): boolean notifyCellUnmounted(cellKey: string): void @@ -3059,6 +3237,7 @@ declare type ListRenderItem = ( ) => React.ReactNode declare type ListRenderItemInfo = { index: number + isSelected: boolean | undefined item: ItemT separators: Separators } @@ -3108,6 +3287,18 @@ declare type MacOSPlatform = { get isVision(): boolean get Version(): string } +declare type MacOSViewProps = { + readonly acceptsFirstMouse?: boolean + readonly allowsVibrancy?: boolean + readonly draggedTypes?: DraggedTypesType + readonly enableFocusRing?: boolean + readonly inverted?: boolean + readonly mouseDownCanMoveWindow?: boolean + readonly tooltip?: string + readonly onDragEnter?: (event: DragEvent) => void + readonly onDragLeave?: (event: DragEvent) => void + readonly onDrop?: (event: DragEvent) => void +} declare type Mapping = | AnimatedValue_default | AnimatedValueXY_default @@ -3201,6 +3392,7 @@ declare type MouseEvent = NativeSyntheticEvent<{ readonly timestamp: number }> declare type MouseEventProps = { + readonly onDoubleClick?: (event: MouseEvent) => void readonly onMouseEnter?: (event: MouseEvent) => void readonly onMouseLeave?: (event: MouseEvent) => void } @@ -3324,6 +3516,7 @@ declare type NativeScrollEvent = { readonly contentOffset: NativeScrollPoint readonly contentSize: NativeScrollSize readonly layoutMeasurement: NativeScrollSize + readonly preferredScrollerStyle?: string readonly responderIgnoreScroll?: boolean readonly targetContentOffset?: NativeScrollPoint readonly velocity?: NativeScrollVelocity @@ -3381,13 +3574,18 @@ declare type NativeTextProps = Readonly< } > declare type NativeTouchEvent = { + readonly altKey?: boolean + readonly button?: number readonly changedTouches: ReadonlyArray + readonly ctrlKey?: boolean readonly force?: number readonly identifier: number readonly locationX: number readonly locationY: number + readonly metaKey?: boolean readonly pageX: number readonly pageY: number + readonly shiftKey?: boolean readonly target: number | undefined readonly timestamp: number readonly touches: ReadonlyArray @@ -3431,6 +3629,7 @@ declare type OnAnimationDidFailCallback = () => void declare type OpaqueColorValue = NativeColorValue declare type OptionalFlatListProps = { columnWrapperStyle?: ViewStyleProp + enableSelectionOnKeyPress?: boolean extraData?: any fadingEdgeLength?: | (number | undefined) @@ -3441,6 +3640,7 @@ declare type OptionalFlatListProps = { horizontal?: boolean initialNumToRender?: number initialScrollIndex?: number + initialSelectedIndex?: number inverted?: boolean keyExtractor?: (item: ItemT, index: number) => string numColumns?: number @@ -3479,9 +3679,9 @@ declare type OptionalVirtualizedListProps = { keyExtractor?: (item: Item, index: number) => string ListEmptyComponent?: React.ComponentType | React.JSX.Element ListFooterComponent?: React.ComponentType | React.JSX.Element - ListFooterComponentStyle?: StyleProp + ListFooterComponentStyle?: StyleProp_2 ListHeaderComponent?: React.ComponentType | React.JSX.Element - ListHeaderComponentStyle?: StyleProp + ListHeaderComponentStyle?: StyleProp_2 ListItemComponent?: React.ComponentType | React.JSX.Element maxToRenderPerBatch?: number onEndReached?: (info: { distanceFromEnd: number }) => void @@ -3516,7 +3716,7 @@ declare type OptionalVirtualizedListProps = { length: number offset: number } - renderScrollComponent?: (props: ScrollViewProps) => React.JSX.Element + renderScrollComponent?: (props: ScrollViewProps_2) => React.JSX.Element } declare type OptionalVirtualizedSectionListProps< ItemT, @@ -3529,6 +3729,7 @@ declare type OptionalVirtualizedSectionListProps< stickySectionHeadersEnabled?: boolean renderItem?: (info: { index: number + isSelected: boolean | undefined item: ItemT section: SectionT separators: { @@ -3591,6 +3792,11 @@ declare type PassThroughProps = { readonly passthroughAnimatedPropExplicitValues?: null | ViewProps } declare type PasswordRules = string +declare type PastedTypesType = PasteType | ReadonlyArray +declare type PasteEvent = NativeSyntheticEvent<{ + readonly dataTransfer: DataTransfer +}> +declare type PasteType = "fileUrl" | "image" | "string" declare type Permission = PermissionsType[keyof PermissionsType] declare type PermissionsAndroid = typeof PermissionsAndroid declare class PermissionsAndroidImpl { @@ -3753,10 +3959,12 @@ declare type PressabilityConfig = { readonly disabled?: boolean readonly hitSlop?: RectOrSize readonly minPressDuration?: number - readonly onBlur?: (event: BlurEvent) => unknown - readonly onFocus?: (event: FocusEvent) => unknown + readonly onBlur?: (event: BlurEvent) => void + readonly onFocus?: (event: FocusEvent) => void readonly onHoverIn?: (event: MouseEvent) => unknown readonly onHoverOut?: (event: MouseEvent) => unknown + readonly onKeyDown?: (event: KeyEvent) => void + readonly onKeyUp?: (event: KeyEvent) => void readonly onLongPress?: (event: GestureResponderEvent) => unknown readonly onPress?: (event: GestureResponderEvent) => unknown readonly onPressIn?: (event: GestureResponderEvent) => unknown @@ -3772,6 +3980,8 @@ declare type PressableAndroidRippleConfig = { radius?: number } declare type PressableBaseProps = { + readonly acceptsFirstMouse?: boolean + readonly allowsVibrancy?: boolean readonly android_disableSound?: boolean readonly android_ripple?: PressableAndroidRippleConfig readonly blockNativeResponder?: boolean @@ -3783,9 +3993,18 @@ declare type PressableBaseProps = { readonly delayHoverOut?: number readonly delayLongPress?: number readonly disabled?: boolean + readonly draggedTypes?: DraggedTypesType + readonly enableFocusRing?: boolean readonly hitSlop?: RectOrSize + readonly keyDownEvents?: Array + readonly keyUpEvents?: Array + readonly mouseDownCanMoveWindow?: boolean + readonly onBlur?: (event: BlurEvent) => void + readonly onFocus?: (event: FocusEvent) => void readonly onHoverIn?: (event: MouseEvent) => unknown readonly onHoverOut?: (event: MouseEvent) => unknown + readonly onKeyDown?: (event: KeyEvent) => void + readonly onKeyUp?: (event: KeyEvent) => void readonly onLayout?: (event: LayoutChangeEvent) => unknown readonly onLongPress?: (event: GestureResponderEvent) => unknown readonly onPress?: (event: GestureResponderEvent) => unknown @@ -3798,6 +4017,10 @@ declare type PressableBaseProps = { | ViewStyleProp readonly testID?: string readonly testOnly_pressed?: boolean + readonly tooltip?: string + readonly onDragEnter?: (event: DragEvent) => void + readonly onDragLeave?: (event: DragEvent) => void + readonly onDrop?: (event: DragEvent) => void } declare type PressableProps = Readonly< Omit & PressableBaseProps @@ -4333,8 +4556,10 @@ declare type ScrollViewBaseProps = { readonly contentOffset?: PointProp readonly decelerationRate?: DecelerationRateType readonly disableIntervalMomentum?: boolean + readonly hasOverlayStyleIndicator?: boolean readonly horizontal?: boolean readonly innerViewRef?: React.Ref + readonly inverted?: boolean readonly invertStickyHeaders?: boolean readonly keyboardDismissMode?: "interactive" | "none" | "on-drag" readonly keyboardShouldPersistTaps?: @@ -4638,6 +4863,11 @@ declare function setSurfaceProps( appParameters: Object, displayMode?: number, ): void +declare type SettingChangeEvent = NativeSyntheticEvent<{ + readonly autoCorrectEnabled: boolean + readonly grammarCheckEnabled: boolean + readonly spellCheckEnabled: boolean +}> declare type Settings = typeof Settings declare function setWrapperComponentProvider( provider: WrapperComponentProvider, @@ -4882,6 +5112,7 @@ declare type State = { firstVisibleItemKey: string | undefined pendingScrollUpdateCount: number renderMask: CellRenderMask + selectedRowIndex: number } declare class StateSafePureComponent_default< Props, @@ -4939,9 +5170,9 @@ declare type StatusBarPropsIOS = { readonly showHideTransition?: "fade" | "none" | "slide" } declare type StatusBarStyle = keyof { - "dark-content": string + "dark-content": ColorValue default: string - "light-content": string + "light-content": ColorValue } declare type StickyHeaderComponentType = ( props: ScrollViewStickyHeaderProps & { @@ -4970,6 +5201,14 @@ declare namespace StyleSheet { } } declare type SubmitBehavior = "blurAndSubmit" | "newline" | "submit" +declare type SubmitKeyEvent = { + readonly altKey?: boolean + readonly ctrlKey?: boolean + readonly functionKey?: boolean + readonly key: string + readonly metaKey?: boolean + readonly shiftKey?: boolean +} declare type subtract = typeof subtract declare type Switch = typeof Switch declare type SwitchChangeEvent = NativeSyntheticEvent @@ -5013,6 +5252,12 @@ declare type SwitchPropsIOS = { declare type SwitchRef = React.ComponentRef< typeof $$AndroidSwitchNativeComponent | typeof $$SwitchNativeComponent > +declare type SystemEffectMacOS = + | "deepPressed" + | "disabled" + | "none" + | "pressed" + | "rollover" declare namespace Systrace { export { isEnabled, @@ -5225,7 +5470,7 @@ declare type TextInputBaseProps = { readonly onChangeText?: (text: string) => unknown readonly onContentSizeChange?: (e: TextInputContentSizeChangeEvent) => unknown readonly onEndEditing?: (e: TextInputEndEditingEvent) => unknown - readonly onFocus?: (e: TextInputFocusEvent) => unknown + readonly onFocus?: (e: TextInputFocusEvent) => void readonly onKeyPress?: (e: TextInputKeyPressEvent) => unknown readonly onPress?: (event: GestureResponderEvent) => unknown readonly onPressIn?: (event: GestureResponderEvent) => unknown @@ -5263,6 +5508,8 @@ declare type TextInputComponentStatics = { readonly currentlyFocusedField: () => number | undefined readonly currentlyFocusedInput: () => HostInstance | undefined readonly focusTextInput: (textField: HostInstance | undefined) => void + readonly onTextInputBlur: (textField: HostInstance | undefined) => void + readonly onTextInputFocus: (textField: HostInstance | undefined) => void } } declare type TextInputContentSizeChangeEvent = @@ -5327,11 +5574,28 @@ declare type TextInputKeyPressEventData = Readonly< target?: number } > +declare type TextInputMacOSProps = { + readonly clearTextOnSubmit?: boolean + readonly grammarCheck?: boolean + readonly hideVerticalScrollIndicator?: boolean + readonly keyDownEvents?: ReadonlyArray + readonly keyUpEvents?: ReadonlyArray + readonly onAutoCorrectChange?: (e: SettingChangeEvent) => unknown + readonly onGrammarCheckChange?: (e: SettingChangeEvent) => unknown + readonly onKeyDown?: (e: KeyEvent) => unknown + readonly onKeyUp?: (e: KeyEvent) => unknown + readonly onSpellCheckChange?: (e: SettingChangeEvent) => unknown + readonly pastedTypes?: PastedTypesType + readonly submitKeyEvents?: ReadonlyArray + readonly tooltip?: string + readonly onPaste?: (event: PasteEvent) => void +} declare type TextInputProps = Readonly< Omit & TextInputIOSProps & TextInputAndroidProps & - TextInputBaseProps + TextInputBaseProps & + TextInputMacOSProps > declare type TextInputSelectionChangeEvent = NativeSyntheticEvent @@ -5371,6 +5635,7 @@ declare type TextProps = Readonly< TextPointerEventProps & TextPropsIOS & TextPropsAndroid & + TextPropsMacOS & TextBaseProps & AccessibilityProps > @@ -5399,6 +5664,11 @@ declare type TextPropsIOS = { lineBreakStrategyIOS?: "hangul-word" | "none" | "push-out" | "standard" suppressHighlighting?: boolean } +declare type TextPropsMacOS = { + enableFocusRing?: boolean + focusable?: boolean + tooltip?: string +} declare type TextStyle = ____TextStyle_Internal declare type TextStyleProp = ____TextStyleProp_Internal declare type Timespan = { @@ -5558,8 +5828,8 @@ declare type TouchableWithoutFeedbackProps = Readonly< importantForAccessibility?: "auto" | "no-hide-descendants" | "no" | "yes" nativeID?: string onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown - onBlur?: (event: BlurEvent) => unknown - onFocus?: (event: FocusEvent) => unknown + onBlur?: (event: BlurEvent) => void + onFocus?: (event: FocusEvent) => void onLayout?: (event: LayoutChangeEvent) => unknown onLongPress?: (event: GestureResponderEvent) => unknown onPress?: (event: GestureResponderEvent) => unknown @@ -5574,7 +5844,17 @@ declare type TouchableWithoutFeedbackProps = Readonly< declare type TouchableWithoutFeedbackPropsAndroid = { touchSoundDisabled?: boolean } -declare type TouchableWithoutFeedbackPropsIOS = {} +declare type TouchableWithoutFeedbackPropsIOS = { + acceptsFirstMouse?: boolean + draggedTypes?: DraggedTypesType + enableFocusRing?: boolean + tooltip?: string + onDragEnter?: (event: DragEvent) => void + onDragLeave?: (event: DragEvent) => void + onDrop?: (event: DragEvent) => void + onMouseEnter?: (event: MouseEvent) => void + onMouseLeave?: (event: MouseEvent) => void +} declare type TouchEventProps = { readonly onTouchCancel?: (e: GestureResponderEvent) => void readonly onTouchCancelCapture?: (e: GestureResponderEvent) => void @@ -5738,8 +6018,10 @@ declare type ViewProps = Readonly< PointerEventProps & FocusEventProps & TouchEventProps & + KeyboardEventProps & ViewPropsAndroid & ViewPropsIOS & + MacOSViewProps & AccessibilityProps & ViewBaseProps > @@ -5773,14 +6055,15 @@ declare class VirtualizedList_default extends StateSafePureComponent_default< componentDidUpdate(prevProps: VirtualizedListProps): void componentWillUnmount(): void constructor(props: VirtualizedListProps) + ensureItemAtIndexIsVisible(rowIndex: number): void flashScrollIndicators(): void static getDerivedStateFromProps( newProps: VirtualizedListProps, prevState: State, ): State getScrollableNode(): null | number | undefined - getScrollRef(): null | React.ComponentRef | undefined - getScrollResponder(): null | ScrollResponderType | undefined + getScrollRef(): null | React.ComponentRef | undefined + getScrollResponder(): null | ScrollResponderType_2 | undefined hasMore(): boolean measureLayoutRelativeToContainingList(): void recordInteraction(): void @@ -5806,6 +6089,7 @@ declare class VirtualizedList_default extends StateSafePureComponent_default< viewPosition?: number }): void scrollToOffset(params: { animated?: boolean; offset: number }): void + selectRowAtIndex(rowIndex: number): void setNativeProps(props: Object): void } declare type VirtualizedListContext = typeof VirtualizedListContext @@ -5814,9 +6098,70 @@ declare function VirtualizedListContextResetter($$PARAM_0$$: { }): React.ReactNode declare type VirtualizedListContextResetterT = typeof VirtualizedListContextResetter -declare type VirtualizedListProps = ScrollViewProps & +declare type VirtualizedListMacOSProps = { + enableSelectionOnKeyPress?: boolean + initialSelectedIndex?: number + onSelectionChanged?: (info: { + item: Item | undefined + newSelection: number + previousSelection: number + }) => void + onSelectionEntered?: (item: Item | undefined) => void + rowIndex?: number + sectionIndex?: number +} +declare type VirtualizedListProps = Omit< + ScrollViewProps_2, + | "data" + | "getItem" + | "getItemCount" + | "enableSelectionOnKeyPress" + | "initialSelectedIndex" + | "onSelectionChanged" + | "onSelectionEntered" + | "rowIndex" + | "sectionIndex" + | "CellRendererComponent" + | "debug" + | "disableVirtualization" + | "extraData" + | "getItemLayout" + | "horizontal" + | "initialNumToRender" + | "initialScrollIndex" + | "inverted" + | "ItemSeparatorComponent" + | "keyExtractor" + | "ListEmptyComponent" + | "ListFooterComponent" + | "ListFooterComponentStyle" + | "ListHeaderComponent" + | "ListHeaderComponentStyle" + | "ListItemComponent" + | "maxToRenderPerBatch" + | "onEndReached" + | "onEndReachedThreshold" + | "onRefresh" + | "onScrollToIndexFailed" + | "onStartReached" + | "onStartReachedThreshold" + | "onViewableItemsChanged" + | "persistentScrollbar" + | "progressViewOffset" + | "refreshControl" + | "refreshing" + | "removeClippedSubviews" + | "renderItem" + | "renderScrollComponent" + | "updateCellsBatchingPeriod" + | "viewabilityConfig" + | "viewabilityConfigCallbackPairs" + | "windowSize" + | never +> & RequiredVirtualizedListProps & - OptionalVirtualizedListProps + OptionalVirtualizedListProps & + VirtualizedListMacOSProps declare type VirtualizedListT = typeof VirtualizedList_default declare type VirtualizedListType = typeof $$index.VirtualizedList declare type VirtualizedSectionList = typeof VirtualizedSectionList @@ -5914,23 +6259,23 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // f6181a2c AccessibilityInfo, // 70604904 - AccessibilityProps, // 5a2836fc - AccessibilityRole, // f2f2e066 + AccessibilityProps, // d961eb5c + AccessibilityRole, // 3cf8c6c5 AccessibilityState, // b0c2b3f7 AccessibilityValue, // cf8bcb74 ActionSheetIOS, // 88e6bfb0 ActionSheetIOSOptions, // 1756eb5a ActivityIndicator, // 8d041a45 - ActivityIndicatorProps, // 0fa4e79d - Alert, // 5bf12165 + ActivityIndicatorProps, // 3e85d5eb + Alert, // 24958ab5 AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 - AlertOptions, // a0cdac0f + AlertOptions, // 39b16cfa AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // 6b6a0b2e + Animated, // df69db8d AppConfig, // ebddad4b - AppRegistry, // 6cdee1d6 + AppRegistry, // eb54df94 AppState, // f7097b1b AppStateEvent, // 80f034c3 AppStateStatus, // 447e5ef2 @@ -5941,14 +6286,15 @@ export { BlurEvent, // 870b9bb5 BoxShadowValue, // b679703f Button, // dd130b61 - ButtonProps, // 3c081e75 + ButtonProps, // 9ec3afed Clipboard, // 9b8c878e CodegenTypes, // 030a94b8 ColorSchemeName, // 31a4350e ColorValue, // 98989a8f + ColorWithSystemEffectMacOS, // df1263b4 ComponentProvider, // b5c60ddd ComponentProviderInstrumentationHook, // 9f640048 - CursorValue, // 26522595 + CursorValue, // 2c77888f DevMenu, // 99e9fcd6 DevSettings, // 1a2f3a5f DeviceEventEmitter, // 31dc96e7 @@ -5960,11 +6306,13 @@ export { DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb DrawerLayoutAndroid, // 14121b61 - DrawerLayoutAndroidProps, // 123d3a9d + DrawerLayoutAndroidProps, // 094b05e9 DrawerSlideEvent, // cc43db83 DropShadowValue, // e9df2606 DynamicColorIOS, // 1f9b3410 DynamicColorIOSTuple, // 023ce58e + DynamicColorMacOS, // f47c1c7d + DynamicColorMacOSTuple, // 692ec192 Easing, // b624f91d EasingFunction, // 14aee4c0 EdgeInsetsValue, // bd44afe6 @@ -5974,12 +6322,12 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // cbb48cbe - FlatListProps, // 451be810 + FlatList, // a2024a35 + FlatListProps, // 3069cec7 FocusEvent, // 529b43eb FontVariant, // 7c7558bb - GestureResponderEvent, // b466f6d6 - GestureResponderHandlers, // 8356843d + GestureResponderEvent, // 52d0886d + GestureResponderHandlers, // 1c246fde Handle, // 2d65285d HostComponent, // 5e13ff5a HostInstance, // 489cbe7f @@ -5987,30 +6335,30 @@ export { IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece Image, // 04474205 - ImageBackground, // 489b1c17 - ImageBackgroundProps, // 1b209e36 + ImageBackground, // 207a3d82 + ImageBackgroundProps, // dec98729 ImageErrorEvent, // b7b2ae63 ImageLoadEvent, // 5baae813 ImageProgressEventIOS, // adb35052 - ImageProps, // 40c727e1 + ImageProps, // 8724fc7d ImagePropsAndroid, // 9fd9bcbb - ImagePropsBase, // 715b84bf + ImagePropsBase, // a2ad423b ImagePropsIOS, // 318adce2 ImageRequireSource, // 681d683b ImageResolvedAssetSource, // f3060931 ImageSize, // 1c47cf88 ImageSource, // 48c7f316 ImageSourcePropType, // bfb5e5c6 - ImageStyle, // 8b22ac76 + ImageStyle, // 6d9dfbb6 ImageURISource, // 016eb083 InputAccessoryView, // 591855d8 - InputAccessoryViewProps, // 4b6f5450 + InputAccessoryViewProps, // fc574890 InputModeOptions, // 4e8581b9 Insets, // e7fe432a InteractionManager, // 301bfa63 Keyboard, // 87311c77 - KeyboardAvoidingView, // d88d0d4c - KeyboardAvoidingViewProps, // bc844418 + KeyboardAvoidingView, // 3511ce7b + KeyboardAvoidingViewProps, // 80af58f8 KeyboardEvent, // c3f895d4 KeyboardEventEasing, // af4091c8 KeyboardEventName, // 59299ad6 @@ -6027,8 +6375,8 @@ export { LayoutConformanceProps, // 055f03b8 LayoutRectangle, // 6601b294 Linking, // 292de0a0 - ListRenderItem, // b5353fd8 - ListRenderItemInfo, // e8595b03 + ListRenderItem, // 3ba527db + ListRenderItemInfo, // d21a2b9b ListViewToken, // 833d3481 LogBox, // b58880c6 LogData, // 89af6d4c @@ -6037,7 +6385,7 @@ export { MeasureOnSuccessCallback, // 82824e59 Modal, // 78e8a79d ModalBaseProps, // 0c81c9b1 - ModalProps, // 270223fa + ModalProps, // c6a42659 ModalPropsAndroid, // 515fb173 ModalPropsIOS, // 4fbcedf6 ModeChangeEvent, // b889a7ce @@ -6053,14 +6401,14 @@ export { NativeModules, // 1cf72876 NativeMouseEvent, // ff25cf35 NativePointerEvent, // 89c1f3ad - NativeScrollEvent, // caad7f53 + NativeScrollEvent, // 431aad2d NativeSyntheticEvent, // d2a1fe6a - NativeTouchEvent, // 59b676df + NativeTouchEvent, // e9fce623 NativeUIEvent, // 44ac26ac Networking, // b674447b OpaqueColorValue, // 25f3fa5b PanResponder, // 98a9b6fc - PanResponderCallbacks, // d325aa56 + PanResponderCallbacks, // 218dfc6b PanResponderGestureState, // 54baf558 PanResponderInstance, // c8b0d00c Permission, // 06473f4f @@ -6075,11 +6423,11 @@ export { PointerEvent, // ff3129ff Pressable, // 3c6e4eb9 PressableAndroidRippleConfig, // 42bc9727 - PressableProps, // 96c8132d + PressableProps, // 5f0ec9ab PressableStateCallbackType, // 9af36561 ProcessedColorValue, // 33f74304 ProgressBarAndroid, // 03e66cf5 - ProgressBarAndroidProps, // 29338dc2 + ProgressBarAndroidProps, // e748eac9 PromiseTask, // 5102c862 PublicRootInstance, // 8040afd7 PublicTextInstance, // 7d73f802 @@ -6088,8 +6436,8 @@ export { PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // 036f45cf - RefreshControlProps, // b7de1e77 + RefreshControl, // b974da75 + RefreshControlProps, // 53497d9a RefreshControlPropsAndroid, // 99f64c97 RefreshControlPropsIOS, // 72a36381 Registry, // e1ed403e @@ -6098,26 +6446,26 @@ export { Role, // af7b889d RootTag, // 3cd10504 RootTagContext, // 15b60335 - RootViewStyleProvider, // cc8d50e9 + RootViewStyleProvider, // 267bc77f Runnable, // 2cb32c54 Runnables, // d3749ae1 SafeAreaView, // 4364c7bb ScaledSize, // 07e417c7 - ScrollEvent, // 84e5b805 + ScrollEvent, // 7d125b6d ScrollResponderType, // d39056e7 ScrollToLocationParamsType, // d7ecdad1 ScrollView, // 7fb7c469 ScrollViewImperativeMethods, // eb20aa46 - ScrollViewProps, // 27986ff5 + ScrollViewProps, // 100d5524 ScrollViewPropsAndroid, // 84e2134b - ScrollViewPropsIOS, // d83c9733 + ScrollViewPropsIOS, // fb759b3a ScrollViewScrollToOptions, // 3313411e SectionBase, // b376bddc - SectionList, // ff1193b2 + SectionList, // 50858078 SectionListData, // 119baf83 - SectionListProps, // c9ac8e07 - SectionListRenderItem, // 1fad0435 - SectionListRenderItemInfo, // 745e1992 + SectionListProps, // 1c9565a0 + SectionListRenderItem, // 9600767c + SectionListRenderItemInfo, // 158b913b Separators, // 6a45f7e3 Settings, // 4282b0da Share, // e4591b32 @@ -6127,16 +6475,16 @@ export { ShareContent, // 7c627896 ShareOptions, // 800c3a4e SimpleTask, // 0e619d11 - StatusBar, // 5e08d563 + StatusBar, // e7cd8aa8 StatusBarAnimation, // 7fd047e6 StatusBarProps, // 06c98add - StatusBarStyle, // 986b2051 + StatusBarStyle, // 49e3b6de StyleProp, // fa0e9b4a StyleSheet, // 366689d4 SubmitBehavior, // c4ddf490 Switch, // aebc9941 SwitchChangeEvent, // 2e5bd2de - SwitchProps, // cb21930d + SwitchProps, // fef27f18 Systrace, // b5aa21fc TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 @@ -6149,24 +6497,24 @@ export { TextInputContentSizeChangeEvent, // 5fba3f54 TextInputEndEditingEvent, // 8c22fac3 TextInputFocusEvent, // c36e977c - TextInputIOSProps, // 0d05a855 + TextInputIOSProps, // e8905f3e TextInputKeyPressEvent, // 967178c2 - TextInputProps, // 8f3237f1 + TextInputProps, // 361942cb TextInputSelectionChangeEvent, // a1a7622f TextInputSubmitEditingEvent, // 48d903af TextLayoutEvent, // 45b0a8d7 - TextProps, // 95d8874d - TextStyle, // f3404e2b + TextProps, // 0c068ca2 + TextStyle, // 88ac4ff3 ToastAndroid, // b4875e35 Touchable, // 93eb6c63 TouchableHighlight, // b4304a98 - TouchableHighlightProps, // c871f353 - TouchableNativeFeedback, // aaa5b42c - TouchableNativeFeedbackProps, // 372d3213 + TouchableHighlightProps, // d9baf596 + TouchableNativeFeedback, // f9c414f6 + TouchableNativeFeedbackProps, // 9587d6ed TouchableOpacity, // 7e33acfd - TouchableOpacityProps, // ba6c0ba4 - TouchableWithoutFeedback, // 7363a906 - TouchableWithoutFeedbackProps, // 68e3d87f + TouchableOpacityProps, // 77031327 + TouchableWithoutFeedback, // 68ac8437 + TouchableWithoutFeedbackProps, // ca6e7192 TransformsStyle, // 65e70f18 TurboModule, // dfe29706 TurboModuleRegistry, // 4ace6db2 @@ -6174,15 +6522,15 @@ export { UTFSequence, // baacd11b Vibration, // 315e131d View, // 39dd4de4 - ViewProps, // f8aca212 - ViewPropsAndroid, // 21385d96 + ViewProps, // 197f314a + ViewPropsAndroid, // 6d846811 ViewPropsIOS, // 58ee19bf - ViewStyle, // c2db0e6e + ViewStyle, // c0170ec0 VirtualViewMode, // 85a69ef6 VirtualizedList, // 4d513939 - VirtualizedListProps, // a99d36db + VirtualizedListProps, // cf03a29a VirtualizedSectionList, // 446ba0df - VirtualizedSectionListProps, // 6cd4b378 + VirtualizedSectionListProps, // e136f7bb WrapperComponentProvider, // 9cf3844c codegenNativeCommands, // e16d62f7 codegenNativeComponent, // ed4c8103 diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 230a3364423f..734947fa5c34 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -35,7 +35,6 @@ "scripts/packager.sh", "scripts/react-native-xcode.sh", "scripts/react_native_pods_utils/script_phases.sh", - "scripts/update-ruby.sh", "scripts/xcode/ccache-clang.sh", "scripts/xcode/ccache-clang++.sh", "scripts/xcode/with-environment.sh", @@ -148,12 +147,12 @@ "scripts/react_native_pods_utils/script_phases.sh", "scripts/react_native_pods.rb", "scripts/react-native-xcode.sh", - "scripts/update-ruby.sh", "scripts/xcode/ccache-clang.sh", "scripts/xcode/ccache-clang++.sh", "scripts/xcode/ccache.conf", "scripts/xcode/with-environment.sh", "sdks/.hermesversion", + "sdks/.hermesv1version", "sdks/hermes-engine/**", "sdks/hermesc", "settings.gradle.kts", @@ -204,6 +203,7 @@ "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", + "metro": "^0.83.3", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", diff --git a/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb b/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb index 7ddbbb36e85f..b687e4ab92d1 100644 --- a/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb +++ b/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb @@ -5,6 +5,7 @@ require "test/unit" require_relative "../utils.rb" +require_relative "../rncore.rb" require_relative "./test_utils/PodMock.rb" require_relative "./test_utils/InstallerMock.rb" require_relative "./test_utils/EnvironmentMock.rb" @@ -768,6 +769,43 @@ def test_creatHeaderSearchPathForFrameworks_whenMultiplePlatformsAndExtraPath_cr # ===================== # # TEST - Add Dependency # # ===================== # + # [macOS + data("normal" => [nil, [""]], + "single platform" => [["iOS"], [""]], + "three platforms" => [["iOS", "macOS", "visionOS"], ["-iOS", "-macOS", "-visionOS"]]) + def test_addDependency_forDynamicPodDependencies_preservesVersionsAndTargetSettings(platforms_and_suffixes) + $RN_PLATFORMS, suffixes = platforms_and_suffixes + ENV['USE_FRAMEWORKS'] = 'dynamic' + + [ + ["React-debug", "React_debug", '1000.0.0'], + ["React-utils", "React_utils", '1000.0.0'], + ["React-featureflags", "React_featureflags", '1000.0.0'], + ["React-RCTFabric", "RCTFabric", nil], + ["ReactCodegen", "ReactCodegen", nil], + ].each do |pod_name, framework_name, version| + spec = SpecMock.new + spec.pod_target_xcconfig = { + "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ReactCommon\"", + "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", + } + + ReactNativePodsUtils.add_dependency(spec, pod_name, "PODS_CONFIGURATION_BUILD_DIR", framework_name, :version => version) + + expected_dependency = {:dependency_name => pod_name} + expected_dependency["version"] = version if version + expected_paths = ["\"$(PODS_TARGET_SRCROOT)/ReactCommon\""] + suffixes.map do |suffix| + "\"${PODS_CONFIGURATION_BUILD_DIR}/#{pod_name}#{suffix}/#{framework_name}.framework/Headers\"" + end + assert_equal([expected_dependency], spec.dependencies, pod_name) + assert_equal({ + "HEADER_SEARCH_PATHS" => expected_paths.join(" "), + "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", + }, spec.to_hash["pod_target_xcconfig"], pod_name) + end + end + # macOS] + def test_addDependency_whenNoHeaderSearchPathAndNoVersion_addsThem spec = SpecMock.new diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-framework-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-framework-test.js new file mode 100644 index 000000000000..d6832b5d80e4 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-framework-test.js @@ -0,0 +1,466 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +// [macOS] + +'use strict'; + +jest.mock('child_process', () => ({execFileSync: jest.fn()})); + +const {recomposeHermesXCFramework} = require('../hermes-framework'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const libraries = [ + { + LibraryIdentifier: 'ios-arm64', + LibraryPath: 'hermesvm.framework', + SupportedPlatform: 'ios', + }, + { + LibraryIdentifier: 'ios-arm64_x86_64-simulator', + LibraryPath: 'hermesvm.framework', + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + }, + { + LibraryIdentifier: 'ios-arm64_x86_64-maccatalyst', + LibraryPath: 'hermesvm.framework', + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'maccatalyst', + }, + { + LibraryIdentifier: 'xros-arm64', + LibraryPath: 'nested path/hermesvm.framework', + SupportedPlatform: 'xros', + }, +]; +const macOSLibrary = { + LibraryIdentifier: 'macos-arm64_x86_64', + LibraryPath: 'hermesvm.framework', + SupportedPlatform: 'macos', +}; +let tmp; +let framework; +let standalone; +let replacement; +let infoPath; + +function writeInfo(folder, availableLibraries) { + fs.writeFileSync( + path.join(folder, 'Info.plist'), + JSON.stringify({AvailableLibraries: availableLibraries}), + ); +} + +function expectOriginalInputs(expectedLibraries = libraries) { + expect(JSON.parse(fs.readFileSync(infoPath, 'utf8'))).toEqual({ + AvailableLibraries: expectedLibraries, + }); + libraries.forEach(library => { + expect( + fs.readFileSync( + path.join( + framework, + library.LibraryIdentifier, + library.LibraryPath, + 'hermesvm', + ), + 'utf8', + ), + ).toBe(library.LibraryIdentifier); + }); + expect(fs.readFileSync(path.join(standalone, 'hermesvm'), 'utf8')).toBe( + 'macOS binary', + ); + expect(fs.readlinkSync(path.join(standalone, 'hermesvm'))).toBe( + 'Versions/Current/hermesvm', + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes framework test-')); + const frameworks = path.join(tmp, 'destroot/Library/Frameworks'); + framework = path.join(frameworks, 'universal/hermesvm.xcframework'); + replacement = path.join(frameworks, 'universal/hermesvm-new.xcframework'); + standalone = path.join(frameworks, 'macosx/hermesvm.framework'); + infoPath = path.join(framework, 'Info.plist'); + fs.mkdirSync(framework, {recursive: true}); + writeInfo(framework, libraries); + libraries.forEach(library => { + const slice = path.join( + framework, + library.LibraryIdentifier, + library.LibraryPath, + ); + fs.mkdirSync(slice, {recursive: true}); + fs.writeFileSync(path.join(slice, 'hermesvm'), library.LibraryIdentifier); + }); + fs.mkdirSync(path.join(standalone, 'Versions/Current'), {recursive: true}); + fs.writeFileSync( + path.join(standalone, 'Versions/Current/hermesvm'), + 'macOS binary', + ); + fs.symlinkSync( + 'Versions/Current/hermesvm', + path.join(standalone, 'hermesvm'), + ); + jest.spyOn(console, 'log').mockImplementation(() => {}); + execFileSync.mockImplementation((command, args) => { + if (command === 'plutil') { + return fs.readFileSync(args[4], 'utf8'); + } + if (command === 'xcodebuild') { + // Exercise the real helper against disk; only simulate Xcode's output. + expectOriginalInputs(); + expect(fs.existsSync(replacement)).toBe(false); + fs.mkdirSync(replacement); + writeInfo(replacement, [...libraries, macOSLibrary]); + return; + } + throw new Error(`Unexpected command: ${command}`); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(tmp, {recursive: true, force: true}); +}); + +test('preserves every plist slice path and replaces only after composition succeeds', () => { + fs.mkdirSync(replacement); + fs.writeFileSync(path.join(replacement, 'stale'), 'old failed output'); + recomposeHermesXCFramework(tmp); + expect(execFileSync).toHaveBeenCalledWith( + 'plutil', + ['-convert', 'json', '-o', '-', infoPath], + {encoding: 'utf8'}, + ); + expect(execFileSync).toHaveBeenCalledWith( + 'xcodebuild', + [ + '-create-xcframework', + ...libraries.flatMap(library => [ + '-framework', + path.join(framework, library.LibraryIdentifier, library.LibraryPath), + ]), + '-framework', + standalone, + '-output', + replacement, + '-allow-internal-distribution', + ], + {stdio: 'inherit'}, + ); + expect(JSON.parse(fs.readFileSync(infoPath, 'utf8'))).toEqual({ + AvailableLibraries: [...libraries, macOSLibrary], + }); + expect(fs.readdirSync(path.dirname(framework))).toEqual([ + 'hermesvm.xcframework', + ]); + expect(fs.readFileSync(path.join(standalone, 'hermesvm'), 'utf8')).toBe( + 'macOS binary', + ); + execFileSync.mockClear(); + recomposeHermesXCFramework(tmp); + expect(execFileSync.mock.calls.map(([command]) => command)).toEqual([ + 'plutil', + ]); +}); + +test('an existing macOS slice needs no standalone framework', () => { + writeInfo(framework, [...libraries, macOSLibrary]); + fs.rmSync(standalone, {recursive: true}); + recomposeHermesXCFramework(tmp); + expect(execFileSync.mock.calls.map(([command]) => command)).toEqual([ + 'plutil', + ]); +}); + +test.each(['plist', 'framework', 'binary', 'broken symlink'])( + 'requires the missing %s unless macOS is optional', + missing => { + const missingPath = + missing === 'plist' + ? infoPath + : missing === 'framework' + ? standalone + : missing === 'binary' + ? path.join(standalone, 'hermesvm') + : path.join(standalone, 'Versions/Current/hermesvm'); + fs.rmSync(missingPath, {recursive: true}); + expect(() => recomposeHermesXCFramework(tmp)).toThrow( + 'Cannot prepare required macOS slice: missing', + ); + expect(() => recomposeHermesXCFramework(tmp, false)).not.toThrow(); + expect( + execFileSync.mock.calls.some(([command]) => command === 'xcodebuild'), + ).toBe(false); + expect(fs.existsSync(framework)).toBe(true); + }, +); + +test.each([true, false])( + 'propagates plist failures even when macOS is optional (%s)', + requireMacOS => { + execFileSync.mockImplementationOnce(() => { + throw new Error('invalid plist'); + }); + expect(() => recomposeHermesXCFramework(tmp, requireMacOS)).toThrow( + 'invalid plist', + ); + expectOriginalInputs(); + execFileSync.mockReturnValueOnce('not JSON'); + expect(() => recomposeHermesXCFramework(tmp, requireMacOS)).toThrow(); + expectOriginalInputs(); + }, +); + +test.each([true, false])( + 'preserves original inputs on failed composition (required: %s)', + requireMacOS => { + const execute = execFileSync.getMockImplementation(); + execFileSync.mockImplementation((command, args) => { + if (command === 'xcodebuild') { + fs.mkdirSync(replacement); + fs.writeFileSync(path.join(replacement, 'partial'), 'failed output'); + throw new Error('unsupported framework input'); + } + return execute(command, args); + }); + expect(() => recomposeHermesXCFramework(tmp, requireMacOS)).toThrow( + 'unsupported framework input', + ); + expectOriginalInputs(); + expect(fs.existsSync(replacement)).toBe(false); + }, +); + +test('restores the original if replacement installation fails', () => { + const rename = fs.renameSync.bind(fs); + jest.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (from === replacement) { + throw new Error('replacement rename failed'); + } + rename(from, to); + }); + expect(() => recomposeHermesXCFramework(tmp)).toThrow( + 'replacement rename failed', + ); + expectOriginalInputs(); + expect(fs.readdirSync(path.dirname(framework))).toEqual([ + 'hermesvm.xcframework', + ]); +}); + +describe('symbol sidecars', () => { + const identifier = libraries[0].LibraryIdentifier; + const dsymName = 'hermesvm.framework.dSYM'; + const mapNames = ['first.bcsymbolmap', 'second.bcsymbolmap']; + const symbolLibraries = [ + { + ...libraries[0], + DebugSymbolsPath: 'original symbols', + BitcodeSymbolMapsPath: 'original maps', + }, + ...libraries.slice(1), + ]; + // Xcode chooses its own output directories; the helper must read them. + const outputLibraries = [ + { + ...libraries[0], + DebugSymbolsPath: 'dSYMs', + BitcodeSymbolMapsPath: 'BCSymbolMaps', + }, + ...libraries.slice(1), + macOSLibrary, + ]; + let inputDSYM; + let inputMaps; + + function writeSymbols(root, library) { + const slice = path.join(root, identifier); + const dsym = path.join(slice, library.DebugSymbolsPath, dsymName); + fs.mkdirSync(path.join(dsym, 'Contents/Resources/DWARF'), { + recursive: true, + }); + fs.writeFileSync(path.join(dsym, 'Contents/Info.plist'), 'dSYM plist'); + fs.writeFileSync( + path.join(dsym, 'Contents/Resources/DWARF/hermesvm'), + 'DWARF data', + ); + const maps = path.join(slice, library.BitcodeSymbolMapsPath); + fs.mkdirSync(maps, {recursive: true}); + mapNames.forEach(name => fs.writeFileSync(path.join(maps, name), name)); + } + + function expectInputSymbols() { + expectOriginalInputs(symbolLibraries); + expect( + fs.readFileSync(path.join(inputDSYM, 'Contents/Info.plist'), 'utf8'), + ).toBe('dSYM plist'); + expect( + fs.readFileSync( + path.join(inputDSYM, 'Contents/Resources/DWARF/hermesvm'), + 'utf8', + ), + ).toBe('DWARF data'); + inputMaps.forEach((file, index) => { + expect(fs.readFileSync(file, 'utf8')).toBe(mapNames[index]); + }); + } + + beforeEach(() => { + writeInfo(framework, symbolLibraries); + writeSymbols(framework, symbolLibraries[0]); + inputDSYM = path.join(framework, identifier, 'original symbols', dsymName); + inputMaps = mapNames.map(name => + path.join(framework, identifier, 'original maps', name), + ); + execFileSync.mockImplementation((command, args) => { + if (command === 'plutil') { + return fs.readFileSync(args[4], 'utf8'); + } + if (command === 'xcodebuild') { + expectInputSymbols(); + expect(args).toEqual([ + '-create-xcframework', + '-framework', + path.join(framework, identifier, libraries[0].LibraryPath), + '-debug-symbols', + inputDSYM, + ...inputMaps.flatMap(file => ['-debug-symbols', file]), + ...libraries + .slice(1) + .flatMap(library => [ + '-framework', + path.join( + framework, + library.LibraryIdentifier, + library.LibraryPath, + ), + ]), + '-framework', + standalone, + '-output', + replacement, + '-allow-internal-distribution', + ]); + fs.mkdirSync(replacement); + writeInfo(replacement, outputLibraries); + writeSymbols(replacement, outputLibraries[0]); + return; + } + throw new Error(`Unexpected command: ${command}`); + }); + }); + + test('passes each actual symbol path and validates Xcode metadata and files', () => { + recomposeHermesXCFramework(tmp); + expect(execFileSync.mock.calls.map(([command]) => command)).toEqual([ + 'plutil', + 'xcodebuild', + 'plutil', + ]); + expect(JSON.parse(fs.readFileSync(infoPath, 'utf8'))).toEqual({ + AvailableLibraries: outputLibraries, + }); + expect( + fs.readFileSync( + path.join( + framework, + identifier, + 'dSYMs', + dsymName, + 'Contents/Resources/DWARF/hermesvm', + ), + 'utf8', + ), + ).toBe('DWARF data'); + mapNames.forEach(name => { + expect( + fs.readFileSync( + path.join(framework, identifier, 'BCSymbolMaps', name), + 'utf8', + ), + ).toBe(name); + }); + expect(fs.readdirSync(path.dirname(framework))).toEqual([ + 'hermesvm.xcframework', + ]); + }); + + test.each(['metadata', 'slice', 'dSYM', 'DWARF', 'map', 'map metadata'])( + 'preserves all inputs when Xcode output lacks %s', + missing => { + const execute = execFileSync.getMockImplementation(); + execFileSync.mockImplementation((command, args) => { + const result = execute(command, args); + if (command === 'xcodebuild') { + const slice = path.join(replacement, identifier); + if (missing === 'metadata') { + writeInfo(replacement, [...libraries, macOSLibrary]); + } else if (missing === 'slice') { + writeInfo(replacement, outputLibraries.slice(1)); + } else if (missing === 'map metadata') { + const {BitcodeSymbolMapsPath, ...library} = outputLibraries[0]; + writeInfo(replacement, [library, ...outputLibraries.slice(1)]); + } else { + const file = + missing === 'map' + ? path.join(slice, 'BCSymbolMaps', mapNames[1]) + : path.join( + slice, + 'dSYMs', + dsymName, + ...(missing === 'DWARF' + ? ['Contents/Resources/DWARF/hermesvm'] + : []), + ); + fs.rmSync(file, {recursive: true}); + } + } + return result; + }); + expect(() => recomposeHermesXCFramework(tmp)).toThrow(); + expectInputSymbols(); + expect(fs.readdirSync(path.dirname(framework))).toEqual([ + 'hermesvm.xcframework', + ]); + }, + ); + + test.each(['directory', 'empty directory', 'DWARF', 'map file'])( + 'rejects invalid input symbols before Xcode: %s', + invalid => { + if (invalid === 'directory') { + fs.rmSync(path.dirname(inputDSYM), {recursive: true}); + } else if (invalid === 'empty directory') { + fs.rmSync(inputDSYM, {recursive: true}); + } else if (invalid === 'DWARF') { + fs.unlinkSync( + path.join(inputDSYM, 'Contents/Resources/DWARF/hermesvm'), + ); + } else { + fs.unlinkSync(inputMaps[0]); + fs.mkdirSync(inputMaps[0]); + } + expect(() => recomposeHermesXCFramework(tmp)).toThrow(); + expectOriginalInputs(symbolLibraries); + expect(execFileSync.mock.calls.map(([command]) => command)).toEqual([ + 'plutil', + ]); + expect(fs.existsSync(replacement)).toBe(false); + }, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js new file mode 100644 index 000000000000..643cdcc43995 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js @@ -0,0 +1,413 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +// [macOS] + +'use strict'; + +jest.mock('child_process', () => ({ + execSync: jest.fn(), + execFileSync: jest.fn(), +})); + +const {prepareHermesArtifactsAsync} = require('../hermes'); +const {execFileSync, execSync} = require('child_process'); +const fs = require('fs'); +const ini = require('ini'); +const os = require('os'); +const path = require('path'); +const {Readable} = require('stream'); + +const propertiesPath = path.resolve( + __dirname, + '../../../sdks/hermes-engine/version.properties', +); +const readFileSync = fs.readFileSync.bind(fs); +const checkedInProperties = readFileSync(propertiesPath, 'utf8'); +const metadata = ini.parse(checkedInProperties); +const originalFetch = global.fetch; +const envKeys = [ + 'RCT_HERMES_V1_ENABLED', + 'HERMES_ENGINE_TARBALL_PATH', + 'HERMES_VERSION', + 'ENTERPRISE_REPOSITORY', +]; +let tmp; +let artifacts; +let versionFile; +let framework; +let savedEnv; +let properties; +let libraries; +let standaloneMacOS; +let includeInfo; + +beforeEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + savedEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); + envKeys.forEach(key => delete process.env[key]); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-test-')); + artifacts = path.join(tmp, '.build/artifacts/hermes'); + versionFile = path.join(artifacts, 'version.txt'); + framework = path.join( + artifacts, + 'destroot/Library/Frameworks/universal/hermesvm.xcframework', + ); + properties = 'HERMES_VERSION_NAME=123.4.56\nHERMES_V1_VERSION_NAME=234.5.67'; + libraries = [{SupportedPlatform: 'macos'}]; + standaloneMacOS = false; + includeInfo = true; + jest.spyOn(process, 'cwd').mockReturnValue(tmp); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...args) => { + if (file === propertiesPath) { + if (properties instanceof Error) { + throw properties; + } + return properties; + } + return readFileSync(file, ...args); + }); + global.fetch = jest.fn(async (url, options) => { + if (options?.method === 'HEAD') { + return {status: 200}; + } + return {ok: true, body: Readable.from(['mock Hermes archive'])}; + }); + execSync.mockImplementation(() => { + fs.mkdirSync(framework, {recursive: true}); + if (includeInfo) { + fs.writeFileSync( + path.join(framework, 'Info.plist'), + JSON.stringify({AvailableLibraries: libraries}), + ); + } + if (standaloneMacOS) { + const macOSFramework = path.resolve( + framework, + '../../macosx/hermesvm.framework', + ); + fs.mkdirSync(macOSFramework, {recursive: true}); + fs.writeFileSync(path.join(macOSFramework, 'hermesvm'), 'macOS binary'); + } + }); + execFileSync.mockImplementation((command, args) => { + if (command === 'plutil') { + return readFileSync(args[4], 'utf8'); + } + if (command === 'xcodebuild') { + const output = args[args.indexOf('-output') + 1]; + fs.mkdirSync(output, {recursive: true}); + fs.writeFileSync( + path.join(output, 'Info.plist'), + JSON.stringify({ + AvailableLibraries: [...libraries, {SupportedPlatform: 'macos'}], + }), + ); + } + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + global.fetch = originalFetch; + envKeys.forEach(key => { + if (savedEnv[key] == null) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + }); + fs.rmSync(tmp, {recursive: true, force: true}); +}); + +function releaseUrl(version, flavor = 'debug') { + return `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/${version}/hermes-ios-${version}-hermes-ios-${flavor}.tar.gz`; +} + +test.each(['Debug', 'Release'])( + 'uses checked-in metadata with the 1000.0.0 RN package for %s', + async flavor => { + properties = checkedInProperties; + const version = metadata.HERMES_VERSION_NAME; + expect(await prepareHermesArtifactsAsync('1000.0.0', flavor)).toBe( + artifacts, + ); + const url = releaseUrl(version, flavor.toLowerCase()); + expect(global.fetch.mock.calls).toEqual([[url, {method: 'HEAD'}], [url]]); + expect(readFileSync(versionFile, 'utf8')).toBe(`${version}-${flavor}`); + expect(fs.existsSync(path.join(artifacts, 'hermes-ios.download'))).toBe( + false, + ); + expect( + fs.existsSync( + path.join(artifacts, `hermes-ios-${version}-${flavor}.tar.gz`), + ), + ).toBe(false); + }, +); + +test('selects V1 metadata only with flag 1', async () => { + properties = checkedInProperties; + process.env.RCT_HERMES_V1_ENABLED = '1'; + await prepareHermesArtifactsAsync('0.83.1', 'Debug'); + expect(global.fetch).toHaveBeenCalledWith( + releaseUrl(metadata.HERMES_V1_VERSION_NAME), + ); +}); + +test.each([ + '', + 'HERMES_VERSION_NAME=^1.2.3', + Object.assign(new Error('missing version.properties'), {code: 'ENOENT'}), +])( + 'fails invalid or missing metadata before network or extraction: %s', + async invalid => { + properties = invalid; + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).rejects.toThrow(); + expect(global.fetch).not.toHaveBeenCalled(); + expect(execSync).not.toHaveBeenCalled(); + }, +); + +test('local tarball overrides invalid metadata and explicit nightly', async () => { + const tarball = path.join(tmp, 'local hermes.tar.gz'); + fs.writeFileSync(tarball, 'local archive'); + fs.mkdirSync(artifacts, {recursive: true}); + fs.writeFileSync(versionFile, 'old-version'); + process.env.HERMES_ENGINE_TARBALL_PATH = tarball; + process.env.HERMES_VERSION = 'nightly'; + properties = ''; + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(execSync).toHaveBeenCalledWith( + `tar -xzf "${tarball}" -C "${artifacts}"`, + {stdio: 'inherit'}, + ); + expect(fs.existsSync(tarball)).toBe(true); + expect(fs.existsSync(versionFile)).toBe(false); + expect(fs.readFileSync).not.toHaveBeenCalledWith(propertiesPath, 'utf8'); + expect(global.fetch).not.toHaveBeenCalled(); +}); + +test.each(['123.4.56', '1000.0.0'])( + 'explicit version %s bypasses metadata', + async version => { + process.env.HERMES_VERSION = version; + properties = new Error('missing metadata'); + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(global.fetch.mock.calls).toEqual([ + [releaseUrl(version), {method: 'HEAD'}], + [releaseUrl(version)], + ]); + expect(fs.readFileSync).not.toHaveBeenCalledWith(propertiesPath, 'utf8'); + }, +); + +test('only explicit nightly resolves the npm tag', async () => { + process.env.HERMES_VERSION = 'nightly'; + properties = ''; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({version: '123.4.57'}), + }); + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(global.fetch.mock.calls).toEqual([ + ['https://registry.npmjs.org/hermes-compiler/nightly'], + [releaseUrl('123.4.57'), {method: 'HEAD'}], + [releaseUrl('123.4.57')], + ]); +}); + +test('an explicit nightly lookup failure does not use the default pin', async () => { + process.env.HERMES_VERSION = 'nightly'; + global.fetch.mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Unavailable', + }); + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).rejects.toThrow("Couldn't get an answer from NPM: 503 Unavailable"); + expect(global.fetch.mock.calls).toEqual([ + ['https://registry.npmjs.org/hermes-compiler/nightly'], + ]); + expect(execSync).not.toHaveBeenCalled(); +}); + +test('main does not infer a source exception from selected metadata 1000.0.0', async () => { + properties = 'HERMES_VERSION_NAME=1000.0.0'; + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(global.fetch.mock.calls).toEqual([ + [releaseUrl('1000.0.0'), {method: 'HEAD'}], + [releaseUrl('1000.0.0')], + ]); +}); + +test('uses the selected pin for snapshot metadata and download', async () => { + const base = + 'https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/123.4.56-SNAPSHOT'; + const url = `${base}/hermes-ios-123.4.56-20260101.010203-4-hermes-ios-debug.tar.gz`; + global.fetch.mockImplementation(async (target, options) => { + if (target.endsWith('/maven-metadata.xml')) { + return { + ok: true, + text: async () => + '20260101.0102034', + }; + } + if (options?.method === 'HEAD') { + return {status: target === url ? 200 : 404}; + } + return {ok: true, body: Readable.from(['snapshot archive'])}; + }); + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(global.fetch.mock.calls).toEqual([ + [releaseUrl('123.4.56'), {method: 'HEAD'}], + [`${base}/maven-metadata.xml`], + [url, {method: 'HEAD'}], + [`${base}/maven-metadata.xml`], + [url], + ]); +}); + +test('preserves the enterprise repository override', async () => { + process.env.ENTERPRISE_REPOSITORY = 'https://mirror.example/maven'; + await prepareHermesArtifactsAsync('0.83.1', 'Release'); + expect(global.fetch).toHaveBeenCalledWith( + releaseUrl('123.4.56', 'release').replace( + 'https://repo1.maven.org/maven2', + process.env.ENTERPRISE_REPOSITORY, + ), + ); +}); + +test('reuses only the matching Hermes version, flag and flavor cache', async () => { + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + global.fetch.mockClear(); + execSync.mockClear(); + await prepareHermesArtifactsAsync('0.83.1', 'Debug'); + expect(global.fetch).not.toHaveBeenCalled(); + expect(execSync).not.toHaveBeenCalled(); + properties = 'HERMES_VERSION_NAME=123.4.58\nHERMES_V1_VERSION_NAME=234.5.67'; + await prepareHermesArtifactsAsync('0.83.1', 'Debug'); + expect(global.fetch).toHaveBeenCalledWith(releaseUrl('123.4.58')); + await prepareHermesArtifactsAsync('0.83.1', 'Release'); + expect(global.fetch).toHaveBeenCalledWith(releaseUrl('123.4.58', 'release')); + process.env.RCT_HERMES_V1_ENABLED = '1'; + await prepareHermesArtifactsAsync('0.83.1', 'Release'); + expect(global.fetch).toHaveBeenCalledWith(releaseUrl('234.5.67', 'release')); +}); + +test('unavailable artifacts fail without an npm or source fallback', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).rejects.toThrow('Failed to download: 404 Not Found'); + expect(execSync).not.toHaveBeenCalled(); + expect(global.fetch.mock.calls.some(([url]) => url.includes('npmjs'))).toBe( + false, + ); +}); + +describe('macOS slice capabilities', () => { + beforeEach(() => { + libraries = [ + { + LibraryIdentifier: 'ios-arm64', + LibraryPath: 'hermesvm.framework', + SupportedPlatform: 'ios', + }, + ]; + }); + + test.each(['download', 'cache', 'local'])( + 'recomposes older artifacts from %s before returning', + async source => { + standaloneMacOS = true; + if (source === 'local') { + process.env.HERMES_ENGINE_TARBALL_PATH = path.join(tmp, 'local.tar.gz'); + } + if (source === 'cache') { + execSync(); // Populate the old extracted layout without recomposition. + fs.writeFileSync(versionFile, '123.4.56-Debug'); + execSync.mockClear(); + } + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(execFileSync).toHaveBeenCalledWith( + 'xcodebuild', + expect.arrayContaining(['-create-xcframework']), + {stdio: 'inherit'}, + ); + expect( + JSON.parse(readFileSync(path.join(framework, 'Info.plist'), 'utf8')) + .AvailableLibraries, + ).toEqual([...libraries, {SupportedPlatform: 'macos'}]); + if (source !== 'download') { + expect(global.fetch).not.toHaveBeenCalled(); + } + if (source === 'cache') { + expect(execSync).not.toHaveBeenCalled(); + } + }, + ); + + test('checks existing macOS support after download and on cache reuse', async () => { + libraries.push({SupportedPlatform: 'macos'}); + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(execSync).toHaveBeenCalledTimes(1); + expect(execFileSync.mock.calls.map(([command]) => command)).toEqual([ + 'plutil', + 'plutil', + ]); + }); + + test.each(['plist', 'binary'])( + 'rejects missing required %s after download and on cache reuse', + async missing => { + includeInfo = missing !== 'plist'; + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).rejects.toThrow('Cannot prepare required macOS slice: missing'); + global.fetch.mockClear(); + execSync.mockClear(); + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).rejects.toThrow('Cannot prepare required macOS slice: missing'); + expect(global.fetch).not.toHaveBeenCalled(); + expect(execSync).not.toHaveBeenCalled(); + }, + ); + + test.each([true, false])( + 'permits local tarballs without macOS inputs (plist: %s)', + async hasInfo => { + includeInfo = hasInfo; + const tarball = path.join(tmp, 'local.tar.gz'); + fs.writeFileSync(tarball, 'local archive'); + process.env.HERMES_ENGINE_TARBALL_PATH = tarball; + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).resolves.toBe(artifacts); + expect(fs.existsSync(tarball)).toBe(true); + expect(global.fetch).not.toHaveBeenCalled(); + expect( + execFileSync.mock.calls.some(([command]) => command === 'xcodebuild'), + ).toBe(false); + }, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js new file mode 100644 index 000000000000..102b63c0c974 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js @@ -0,0 +1,135 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +// [macOS] + +'use strict'; + +const {parseHermesMetadata, readHermesMetadata} = require('../hermes-version'); +const fs = require('fs'); +const ini = require('ini'); +const path = require('path'); + +const properties = + 'HERMES_VERSION_NAME=123.4.56\nHERMES_V1_VERSION_NAME=234.5.67\n'; + +test.each([ + ['legacy-default', undefined, false], + ['legacy-default', '0', false], + ['legacy-default', '1', true], + ['legacy-default', '', false], + ['legacy-default', 'true', false], + ['v1-default', undefined, true], + ['v1-default', '0', false], + ['v1-default', '1', true], + ['v1-default', '', true], + ['v1-default', 'true', true], + ['single', undefined, false], + ['single', '0', false], + ['single', '1', false], +])('%s with flag %s selects the exact key and tag file', (policy, flag, v1) => { + expect(parseHermesMetadata(properties, policy, flag)).toEqual({ + version: v1 ? '234.5.67' : '123.4.56', + versionKey: v1 ? 'HERMES_V1_VERSION_NAME' : 'HERMES_VERSION_NAME', + tagFile: v1 ? '.hermesv1version' : '.hermesversion', + }); +}); + +test('the pure parser defaults to legacy without reading the environment', () => { + const previous = process.env.RCT_HERMES_V1_ENABLED; + try { + process.env.RCT_HERMES_V1_ENABLED = '1'; + expect(parseHermesMetadata(properties).version).toBe('123.4.56'); + } finally { + if (previous == null) { + delete process.env.RCT_HERMES_V1_ENABLED; + } else { + process.env.RCT_HERMES_V1_ENABLED = previous; + } + } +}); + +test.each([ + '0.14.0', + '250829098.0.2', + '1.2.3-rc.1', + '1.2.3+build.1', + '1000.0.0', +])('accepts exact version %s with comments, whitespace and CRLF', version => { + expect( + parseHermesMetadata( + `# Hermes pin\r\n! comment\r\n OTHER_KEY=ignored\r\n HERMES_VERSION_NAME = ${version} \r\n`, + ).version, + ).toBe(version); +}); + +test.each([ + '', + 'HERMES_V1_VERSION_NAME=1.2.3', + 'OTHER_HERMES_VERSION_NAME=1.2.3', + '# HERMES_VERSION_NAME=1.2.3', + 'HERMES_VERSION_NAME=', + 'HERMES_VERSION_NAME=nightly', + 'HERMES_VERSION_NAME=latest-v1', + 'HERMES_VERSION_NAME=^1.2.3', + 'HERMES_VERSION_NAME=~1.2.3', + 'HERMES_VERSION_NAME=1.2.x', + 'HERMES_VERSION_NAME=>=1.2.3', + 'HERMES_VERSION_NAME=1.2.3 || 2.0.0', + 'HERMES_VERSION_NAME=v1.2.3', + 'HERMES_VERSION_NAME=01.2.3', + 'HERMES_VERSION_NAME=1.2.3-01', + 'HERMES_VERSION_NAME=1.2.3=invalid', + 'HERMES_VERSION_NAME=1.2.3 # comment', + 'HERMES_VERSION_NAME=1.2.3\n HERMES_VERSION_NAME = 1.2.3', + 'HERMES_VERSION_NAME=1.2.3\nHERMES_VERSION_NAME=2.0.0', +])('rejects invalid selected metadata: %s', input => { + expect(() => parseHermesMetadata(input)).toThrow( + 'Expected one exact HERMES_VERSION_NAME', + ); +}); + +test('validates only the selected key without a fallback to another key', () => { + const input = 'HERMES_VERSION_NAME=invalid\nHERMES_V1_VERSION_NAME=1.2.3'; + expect(parseHermesMetadata(input, 'legacy-default', '1').version).toBe( + '1.2.3', + ); + expect(() => parseHermesMetadata(input)).toThrow('HERMES_VERSION_NAME'); + expect(() => + parseHermesMetadata('HERMES_VERSION_NAME=1.2.3', 'v1-default'), + ).toThrow('HERMES_V1_VERSION_NAME'); + expect(() => + parseHermesMetadata( + `${properties}HERMES_V1_VERSION_NAME=1.2.3`, + 'v1-default', + ), + ).toThrow('HERMES_V1_VERSION_NAME'); +}); + +test('rejects an unknown policy', () => { + expect(() => parseHermesMetadata(properties, 'guess')).toThrow( + 'Unknown Hermes metadata policy', + ); +}); + +test('reads checked-in metadata relative to the helper', () => { + const metadata = ini.parse( + fs.readFileSync( + path.resolve(__dirname, '../../../sdks/hermes-engine/version.properties'), + 'utf8', + ), + ); + expect(readHermesMetadata('legacy-default', '0').version).toBe( + metadata.HERMES_VERSION_NAME, + ); + expect(readHermesMetadata('legacy-default', '1').version).toBe( + metadata.HERMES_V1_VERSION_NAME, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/cli.js b/packages/react-native/scripts/ios-prebuild/cli.js index 86b639d00ad8..5f3886a2651f 100644 --- a/packages/react-native/scripts/ios-prebuild/cli.js +++ b/packages/react-native/scripts/ios-prebuild/cli.js @@ -100,6 +100,7 @@ async function getCLIConfiguration() /*: Promise */ { + const keys /*: Array<'DebugSymbolsPath' | 'BitcodeSymbolMapsPath'> */ = [ + 'DebugSymbolsPath', + 'BitcodeSymbolMapsPath', + ]; + return keys.flatMap(key => { + if (library[key] == null) { + return []; + } + const directory = path.resolve(slicePath, library[key]); + const extension = key === 'DebugSymbolsPath' ? '.dSYM' : '.bcsymbolmap'; + const symbols = fs + .readdirSync(directory) + .filter(name => name.endsWith(extension)) + .map(name => path.join(directory, name)); + if (symbols.length === 0) { + throw new Error(`[Hermes] Missing symbol sidecars in ${directory}`); + } + for (const symbol of symbols) { + const files = + key === 'DebugSymbolsPath' + ? [ + path.join(symbol, 'Contents', 'Info.plist'), + path.join(symbol, 'Contents', 'Resources', 'DWARF', 'hermesvm'), + ] + : [symbol]; + for (const file of files) { + if (!fs.statSync(file).isFile()) { + throw new Error(`[Hermes] Invalid symbol sidecar: ${file}`); + } + } + } + return symbols; + }); +} + +// [macOS] Older artifacts, including main's legacy Hermes artifacts, keep macOS +// outside the universal XCFramework. Check capabilities rather than versions: +// newer defaults do not cover cached artifacts or explicit version overrides. +function recomposeHermesXCFramework( + artifactsPath /*: string */, + requireMacOS /*: boolean */ = true, +) { + const frameworksPath = path.join( + artifactsPath, + 'destroot', + 'Library', + 'Frameworks', + ); + const xcframeworkPath = path.join( + frameworksPath, + 'universal', + 'hermesvm.xcframework', + ); + const infoPath = path.join(xcframeworkPath, 'Info.plist'); + const macOSFrameworkPath = path.join( + frameworksPath, + 'macosx', + 'hermesvm.framework', + ); + + if (!fs.existsSync(infoPath)) { + if (requireMacOS) { + throw new Error( + `[Hermes] Cannot prepare required macOS slice: missing ${infoPath}`, + ); + } + return; + } + + const info = JSON.parse( + execFileSync('plutil', ['-convert', 'json', '-o', '-', infoPath], { + encoding: 'utf8', + }).toString(), + ); + if ( + info.AvailableLibraries.some( + library => library.SupportedPlatform === 'macos', + ) + ) { + return; + } + + const macOSBinaryPath = path.join(macOSFrameworkPath, 'hermesvm'); + if (!fs.existsSync(macOSBinaryPath)) { + if (requireMacOS) { + throw new Error( + `[Hermes] Cannot prepare required macOS slice: missing ${macOSBinaryPath}`, + ); + } + return; + } + + const symbolsByLibrary /*: Map> */ = new Map(); + const frameworkArgs = info.AvailableLibraries.flatMap(library => { + const slicePath = path.join(xcframeworkPath, library.LibraryIdentifier); + const symbols = symbolPaths(slicePath, library); + if (symbols.length > 0) { + symbolsByLibrary.set(library.LibraryIdentifier, symbols); + } + return [ + '-framework', + path.join(slicePath, library.LibraryPath), + ...symbols.flatMap(symbol => ['-debug-symbols', symbol]), + ]; + }); + frameworkArgs.push('-framework', macOSFrameworkPath); + + const replacementPath = path.join( + frameworksPath, + 'universal', + 'hermesvm-new.xcframework', + ); + fs.rmSync(replacementPath, {recursive: true, force: true}); + try { + execFileSync( + 'xcodebuild', + [ + '-create-xcframework', + ...frameworkArgs, + '-output', + replacementPath, + '-allow-internal-distribution', + ], + {stdio: 'inherit'}, + ); + // Trust only Xcode's generated metadata and actual output files. Validate + // before moving the original so a successful command cannot lose symbols. + if (symbolsByLibrary.size > 0) { + const replacementInfo = JSON.parse( + execFileSync( + 'plutil', + [ + '-convert', + 'json', + '-o', + '-', + path.join(replacementPath, 'Info.plist'), + ], + {encoding: 'utf8'}, + ).toString(), + ); + for (const [identifier, symbols] of symbolsByLibrary) { + const library = replacementInfo.AvailableLibraries.find( + entry => entry.LibraryIdentifier === identifier, + ); + const outputSymbols = library + ? symbolPaths(path.join(replacementPath, identifier), library).map( + symbol => path.basename(symbol), + ) + : []; + for (const symbol of symbols) { + if (!outputSymbols.includes(path.basename(symbol))) { + throw new Error( + `[Hermes] Missing recomposed symbol sidecar: ${symbol}`, + ); + } + } + } + } + // Keep the original until the replacement is installed, including if the + // final rename fails after xcodebuild succeeds. + const backupFolder = fs.mkdtempSync(`${xcframeworkPath}-backup-`); + const backupPath = path.join(backupFolder, 'hermesvm.xcframework'); + fs.renameSync(xcframeworkPath, backupPath); + try { + fs.renameSync(replacementPath, xcframeworkPath); + } catch (error) { + fs.renameSync(backupPath, xcframeworkPath); + fs.rmSync(backupFolder, {recursive: true, force: true}); + throw error; + } + fs.rmSync(backupFolder, {recursive: true, force: true}); + } finally { + fs.rmSync(replacementPath, {recursive: true, force: true}); + } + hermesLog( + 'Added the standalone macOS framework to the universal XCFramework', + ); +} + +module.exports = {recomposeHermesXCFramework}; diff --git a/packages/react-native/scripts/ios-prebuild/hermes-version.js b/packages/react-native/scripts/ios-prebuild/hermes-version.js new file mode 100644 index 000000000000..80b82a4a3b9a --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/hermes-version.js @@ -0,0 +1,101 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +// [macOS] + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const semver = require('semver'); + +/*:: +type HermesPolicy = 'legacy-default' | 'v1-default' | 'single'; +type HermesMetadataSelection = { + versionKey: string, + tagFile: string, +}; +type HermesMetadata = { + ...HermesMetadataSelection, + version: string, +}; +*/ + +// Keep policy explicit so later release lines can change their default without +// inferring it from a React Native package version or the available keys. +function selectHermesMetadata( + policy /*: HermesPolicy */ = 'legacy-default', + hermesV1Enabled /*: ?string */, +) /*: HermesMetadataSelection */ { + let useV1; + switch (policy) { + case 'legacy-default': + useV1 = hermesV1Enabled === '1'; + break; + case 'v1-default': + useV1 = hermesV1Enabled !== '0'; + break; + case 'single': + useV1 = false; + break; + default: + throw new Error(`Unknown Hermes metadata policy: ${policy}`); + } + + const versionKey = useV1 ? 'HERMES_V1_VERSION_NAME' : 'HERMES_VERSION_NAME'; + const tagFile = useV1 ? '.hermesv1version' : '.hermesversion'; + return {versionKey, tagFile}; +} + +function parseHermesMetadata( + properties /*: string */, + policy /*: HermesPolicy */ = 'legacy-default', + hermesV1Enabled /*: ?string */, +) /*: HermesMetadata */ { + const {versionKey, tagFile} = selectHermesMetadata(policy, hermesV1Enabled); + const entries = properties.split(/\r?\n/).filter(line => { + const equals = line.indexOf('='); + return equals !== -1 && line.slice(0, equals).trim() === versionKey; + }); + const version = + entries.length === 1 + ? entries[0].slice(entries[0].indexOf('=') + 1).trim() + : ''; + // semver.valid removes build metadata; retain it in the artifact coordinate. + // 1000.0.0 is also an exact version. Any release-specific source exception + // belongs to the caller, not the metadata parser. + if (!version || semver.valid(version) !== version.replace(/\+.*/, '')) { + throw new Error( + `Expected one exact ${versionKey} version in Hermes version.properties`, + ); + } + return {version, versionKey, tagFile}; +} + +function readHermesMetadata( + policy /*: HermesPolicy */ = 'legacy-default', + hermesV1Enabled /*: ?string */ = process.env.RCT_HERMES_V1_ENABLED, +) /*: HermesMetadata */ { + const propertiesPath = path.resolve( + __dirname, + '../../sdks/hermes-engine/version.properties', + ); + return parseHermesMetadata( + fs.readFileSync(propertiesPath, 'utf8'), + policy, + hermesV1Enabled, + ); +} + +module.exports = { + selectHermesMetadata, + parseHermesMetadata, + readHermesMetadata, +}; diff --git a/packages/react-native/scripts/ios-prebuild/hermes.js b/packages/react-native/scripts/ios-prebuild/hermes.js index 72c0c4c2073a..25878355e686 100644 --- a/packages/react-native/scripts/ios-prebuild/hermes.js +++ b/packages/react-native/scripts/ios-prebuild/hermes.js @@ -8,6 +8,8 @@ * @format */ +const {recomposeHermesXCFramework} = require('./hermes-framework'); // [macOS] +const {readHermesMetadata} = require('./hermes-version'); // [macOS] const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); @@ -27,6 +29,8 @@ import type {BuildFlavor, Destination, Platform} from './types'; * version of hermes, use the HERMES_VERSION environment variable. The path to the artifacts will be inside * the .build/artifacts/hermes folder, but this can be overridden by setting the HERMES_ENGINE_TARBALL_PATH * environment variable. If this varuable is set, the script will use the local tarball instead of downloading it. + * [macOS] Without an override, use the selected version.properties pin. Only an explicit + * HERMES_VERSION=nightly resolves the npm nightly tag. */ async function prepareHermesArtifactsAsync( reactNativeVersion /*:string*/, @@ -54,7 +58,9 @@ async function prepareHermesArtifactsAsync( // Only check if the artifacts folder exists if we are not using a local tarball if (!localPath) { // Resolve the version from the environment variable or use the default version - let resolvedVersion = process.env.HERMES_VERSION ?? 'nightly'; + // [macOS] Hermes artifacts use the selected SDK pin, not the RN version. + let resolvedVersion = + process.env.HERMES_VERSION ?? readHermesMetadata().version; if (resolvedVersion === 'nightly') { hermesLog('Using latest nightly tarball'); @@ -93,6 +99,11 @@ async function prepareHermesArtifactsAsync( execSync(`tar -xzf "${localPath}" -C "${artifactsPath}"`, { stdio: 'inherit', }); + // [macOS] All-Apple prebuilds require macOS; local overrides may omit it. + recomposeHermesXCFramework( + artifactsPath, + !hermesEngineTarballEnvvarDefined(), + ); // Delete the tarball after extraction if (!process.env.HERMES_ENGINE_TARBALL_PATH) { @@ -159,6 +170,7 @@ function checkExistingVersion( if (fs.existsSync(versionFilePath) && fs.existsSync(hermesXCFramework)) { const versionFileContent = fs.readFileSync(versionFilePath, 'utf8'); if (versionFileContent.trim() === resolvedVersion) { + recomposeHermesXCFramework(artifactsPath); // [macOS] hermesLog( `Hermes artifacts already downloaded and up to date: ${artifactsPath}`, ); diff --git a/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec b/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec index e08e6c2b0995..a0cffd1930e3 100644 --- a/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec +++ b/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec @@ -13,8 +13,8 @@ begin )', __dir__]).strip ) rescue => e - # Fallback to the parent directory if the above command fails (e.g when building locally in OOT Platform) - react_native_path = File.join(__dir__, "..", "..") + # Fallback to the package directory if the above command fails (e.g when building locally in OOT Platform) + react_native_path = File.join(__dir__, "..") end # package.json diff --git a/packages/react-native/types/modules/Codegen.d.ts b/packages/react-native/types/modules/Codegen.d.ts index 698c922fe6b7..e507d95bf188 100644 --- a/packages/react-native/types/modules/Codegen.d.ts +++ b/packages/react-native/types/modules/Codegen.d.ts @@ -40,8 +40,7 @@ declare module 'react-native/Libraries/Utilities/codegenNativeComponent' { } declare module 'react-native/Libraries/Types/CodegenTypes' { - import type {NativeSyntheticEvent} from 'react-native'; - import type {EventSubscription} from 'react-native/Libraries/vendor/emitter/EventEmitter'; + import type {EventSubscription, NativeSyntheticEvent} from 'react-native'; // Event types // We're not using the PaperName, it is only used to codegen view config settings diff --git a/packages/rn-tester/NativeComponentExample/MyNativeView.podspec b/packages/rn-tester/NativeComponentExample/MyNativeView.podspec index 824028831680..98437e5ff630 100644 --- a/packages/rn-tester/NativeComponentExample/MyNativeView.podspec +++ b/packages/rn-tester/NativeComponentExample/MyNativeView.podspec @@ -19,7 +19,6 @@ Pod::Spec.new do |s| s.author = "Meta Platforms, Inc. and its affiliates" s.source = { :git => "https://github.com/facebook/my-native-view.git", :tag => "#{s.version}" } s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCodegen/ReactCodegen.framework/Headers\"", "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard() } @@ -27,4 +26,5 @@ Pod::Spec.new do |s| s.requires_arc = true install_modules_dependencies(s) + add_dependency(s, "ReactCodegen") # [macOS] end diff --git a/packages/virtualized-lists/LICENSE b/packages/virtualized-lists/LICENSE new file mode 100644 index 000000000000..b93be90515cc --- /dev/null +++ b/packages/virtualized-lists/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/scripts/js-api/build-types/buildApiSnapshot.js b/scripts/js-api/build-types/buildApiSnapshot.js index 33e380764854..ef37f688dce3 100644 --- a/scripts/js-api/build-types/buildApiSnapshot.js +++ b/scripts/js-api/build-types/buildApiSnapshot.js @@ -37,7 +37,11 @@ const inputFilesPostTransforms: $ReadOnlyArray> = [ const postTransforms = ( options: BuildApiSnapshotOptions, + packages: $ReadOnlyArray<{directory: string, name: string}>, ): $ReadOnlyArray> => [ + require('./transforms/typescript/canonicalizeLocalPackageImports')( + packages.map(pkg => pkg.name), + ), require('./transforms/typescript/simplifyTypes'), require('./transforms/typescript/sortProperties'), require('./transforms/typescript/sortUnions'), @@ -85,7 +89,7 @@ async function buildAPISnapshot(options: BuildApiSnapshotOptions) { console.log(styleText('yellow', ' >') + ' Applying additional transforms'); const apiSnapshot = apiSnapshotTemplate( - await getProcessedSnapshotResult(tempDirectory, options), + await getProcessedSnapshotResult(tempDirectory, options, packages), ) as string; console.log(styleText('yellow', ' >') + ' Removing temp dir'); @@ -164,7 +168,10 @@ async function validateSnapshots( async function findPackagesWithTypedef() { const packagesWithGeneratedTypes = glob - .sync(`${PACKAGES_DIR}/**/types_generated`, {nodir: false}) + .sync(`${PACKAGES_DIR}/**/types_generated`, { + nodir: false, + ignore: '**/node_modules/**', // [macOS] Use workspaces, not their dependency links. + }) .map(typesPath => path.relative(PACKAGES_DIR, typesPath).split('/').slice(0, -1).join('/'), ); @@ -242,6 +249,7 @@ async function rewriteLocalImports( async function getProcessedSnapshotResult( tempDirectory: string, options: BuildApiSnapshotOptions, + packages: $ReadOnlyArray<{directory: string, name: string}>, ): Promise { const rollupPath = path.join( tempDirectory, @@ -259,7 +267,7 @@ async function getProcessedSnapshotResult( const transformedRollup = await applyBabelTransformsSeq( cleanedRollup, - postTransforms(options), + postTransforms(options, packages), ); return ( diff --git a/scripts/js-api/build-types/resolution/__tests__/simpleResolve-test.js b/scripts/js-api/build-types/resolution/__tests__/simpleResolve-test.js new file mode 100644 index 000000000000..53cbb0c28fdc --- /dev/null +++ b/scripts/js-api/build-types/resolution/__tests__/simpleResolve-test.js @@ -0,0 +1,65 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const {PACKAGES_DIR} = require('../../../../shared/consts'); +const {promises: fs} = require('fs'); +const glob = require('glob'); +const path = require('path'); + +describe('simpleResolve workspace dependencies', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + test.each([ + [true, undefined, 'index.js'], + [true, 'src/index.js', 'src/index.js'], + [false, undefined, 'index.js'], + ])( + 'resolves a workspace with private=%s and main=%s', + async (isPrivate, main, entryPoint) => { + const packagePath = path.join(PACKAGES_DIR, 'type-dependency'); + jest + .spyOn(glob, 'sync') + .mockReturnValue([path.join(packagePath, 'package.json')]); + jest.spyOn(fs, 'readFile').mockResolvedValue( + JSON.stringify({ + name: '@react-native-macos/type-dependency', + private: isPrivate, + main, + }), + ); + // Load the real package filter with an empty resolver cache each time. + const simpleResolve = require('../simpleResolve'); + const reportUnresolvedDependency = jest.fn(); + + await expect( + simpleResolve( + '@react-native-macos/type-dependency', + path.join(PACKAGES_DIR, 'react-native/index.js.flow'), + {reportUnresolvedDependency}, + ), + ).resolves.toBe(path.join(packagePath, entryPoint)); + expect(reportUnresolvedDependency).not.toHaveBeenCalled(); + + await expect( + simpleResolve( + 'external-package', + path.join(PACKAGES_DIR, 'react-native/index.js.flow'), + {reportUnresolvedDependency}, + ), + ).resolves.toBeNull(); + expect(reportUnresolvedDependency).toHaveBeenCalledWith( + 'external-package', + ); + }, + ); +}); diff --git a/scripts/js-api/build-types/resolution/simpleResolve.js b/scripts/js-api/build-types/resolution/simpleResolve.js index a571f04a6424..c3688625f112 100644 --- a/scripts/js-api/build-types/resolution/simpleResolve.js +++ b/scripts/js-api/build-types/resolution/simpleResolve.js @@ -34,7 +34,7 @@ async function simpleResolve( if (cachedProjectInfo == null) { cachedProjectInfo = await getPackages({ includeReactNative: true, - includePrivate: false, + includePrivate: true, // [macOS] Main keeps the fork's type dependencies private. }); } diff --git a/scripts/js-api/build-types/transforms/typescript/__tests__/canonicalizeLocalPackageImports-test.js b/scripts/js-api/build-types/transforms/typescript/__tests__/canonicalizeLocalPackageImports-test.js new file mode 100644 index 000000000000..7a90e22aa729 --- /dev/null +++ b/scripts/js-api/build-types/transforms/typescript/__tests__/canonicalizeLocalPackageImports-test.js @@ -0,0 +1,80 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const canonicalizeLocalPackageImports = require('../canonicalizeLocalPackageImports'); +const babel = require('@babel/core'); + +async function transform(code: string): Promise { + const result = await babel.transformAsync(code, { + plugins: [ + '@babel/plugin-syntax-typescript', + canonicalizeLocalPackageImports([ + 'react-native-macos', + '@react-native-macos/virtualized-lists', + ]), + ], + }); + return result.code; +} + +describe('canonicalizeLocalPackageImports', () => { + test('normalizes nested node_modules imports and exports', async () => { + const result = await transform(` + import type {Foo} from "../../../jest-preset/node_modules/react-native-macos/node_modules/@react-native-macos/virtualized-lists"; + export {Bar} from "../../node_modules/react-native-macos/src/bar"; + export * from "../../../react-native/node_modules/@react-native-macos/virtualized-lists"; + `); + + expect(result).toBe( + [ + 'import type { Foo } from "@react-native-macos/virtualized-lists";', + 'export { Bar } from "react-native-macos/src/bar";', + 'export * from "@react-native-macos/virtualized-lists";', + ].join('\n'), + ); + }); + + test.each([ + '../../node_modules/react-native-macos-extra', + '../../node_modules/react-native-macos.extra', + '../../node_modules/@react-native-macos/virtualized-lists-extra', + '../../node_modules/react-native-macos/node_modules/unrelated', + 'https://host/node_modules/react-native-macos', + '/absolute/node_modules/react-native-macos', + 'some-package/node_modules/react-native-macos', + '../ordinary/react-native-macos', + 'react-native-macos', + ])('preserves non-local or non-matching source %s', async source => { + expect(await transform(`export * from "${source}";`)).toBe( + `export * from "${source}";`, + ); + }); + + test('preserves ordinary strings and exports without sources', async () => { + expect( + await transform( + 'const value = "../node_modules/react-native-macos"; export {value};', + ), + ).toBe( + 'const value = "../node_modules/react-native-macos";\nexport { value };', + ); + }); + + test('is independent of checkout prefixes and idempotent', async () => { + const first = await transform( + 'export * from "../../one/node_modules/react-native-macos/src/api";', + ); + const second = await transform( + 'export * from "../../../two/node_modules/react-native-macos/src/api";', + ); + expect(first).toBe(second); + expect(await transform(first)).toBe(first); + }); +}); diff --git a/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js b/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js new file mode 100644 index 000000000000..9c97ce7f8118 --- /dev/null +++ b/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js @@ -0,0 +1,65 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {PluginObj} from '@babel/core'; +import type {NodePath} from '@babel/traverse'; +import type { + ExportAllDeclaration, + ExportNamedDeclaration, + ImportDeclaration, +} from '@babel/types'; + +function canonicalizeSource( + source: string, + packageNames: $ReadOnlyArray, +): string { + if (!source.startsWith('./') && !source.startsWith('../')) { + return source; + } + const marker = '/node_modules/'; + const markerIndex = source.lastIndexOf(marker); + if (markerIndex === -1) { + return source; + } + const candidate = source.slice(markerIndex + marker.length); + return packageNames.some( + name => candidate === name || candidate.startsWith(name + '/'), + ) + ? candidate + : source; +} + +function canonicalizeLocalPackageImports( + packageNames: $ReadOnlyArray, +): PluginObj { + function canonicalizeNodeSource( + nodePath: NodePath< + ExportAllDeclaration | ExportNamedDeclaration | ImportDeclaration, + >, + ) { + if (nodePath.node.source != null) { + nodePath.node.source.value = canonicalizeSource( + nodePath.node.source.value, + packageNames, + ); + } + } + + return { + name: 'canonicalize-local-package-imports', + visitor: { + ExportAllDeclaration: canonicalizeNodeSource, + ExportNamedDeclaration: canonicalizeNodeSource, + ImportDeclaration: canonicalizeNodeSource, + }, + }; +} + +module.exports = canonicalizeLocalPackageImports; diff --git a/scripts/releases/ios-prebuild/cli.js b/scripts/releases/ios-prebuild/cli.js index dc0a7f7b7b74..8845e23a0673 100644 --- a/scripts/releases/ios-prebuild/cli.js +++ b/scripts/releases/ios-prebuild/cli.js @@ -108,6 +108,7 @@ async function getCLIConfiguration() /*: Promise