diff --git a/packages/cli/src/commands/init/__tests__/version.test.ts b/packages/cli/src/commands/init/__tests__/version.test.ts new file mode 100644 index 000000000..09a507305 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/version.test.ts @@ -0,0 +1,41 @@ +import {createTemplateUri} from '../version'; +import type {Options} from '../types'; + +const mockGetTemplateVersion = jest.fn(); + +jest.mock('../../../tools/npm', () => ({ + __esModule: true, + getTemplateVersion: (...args) => mockGetTemplateVersion(...args), +})); + +const nullOptions = {} as Options; + +describe('createTemplateUri', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('for < 0.75', () => { + it('use react-native for the template', async () => { + expect(await createTemplateUri(nullOptions, '0.74.1')).toEqual( + 'react-native@0.74.1', + ); + }); + it('looks DOES NOT use npm registry data to find the template', () => { + expect(mockGetTemplateVersion).not.toHaveBeenCalled(); + }); + }); + describe('for >= 0.75', () => { + it('use @react-native-community/template for the template', async () => { + // Imagine for React Native 0.75.1, template 1.2.3 was prepared for this version + mockGetTemplateVersion.mockReturnValue('1.2.3'); + expect(await createTemplateUri(nullOptions, '0.75.1')).toEqual( + '@react-native-community/template@1.2.3', + ); + }); + + it('looks at uses npm registry data to find the matching @react-native-community/template', async () => { + await createTemplateUri(nullOptions, '0.75.0'); + expect(mockGetTemplateVersion).toHaveBeenCalledWith('0.75.0'); + }); + }); +}); diff --git a/packages/cli/src/commands/init/constants.ts b/packages/cli/src/commands/init/constants.ts new file mode 100644 index 000000000..db0df6677 --- /dev/null +++ b/packages/cli/src/commands/init/constants.ts @@ -0,0 +1,7 @@ +export const TEMPLATE_PACKAGE_COMMUNITY = '@react-native-community/template'; +export const TEMPLATE_PACKAGE_LEGACY = 'react-native'; +export const TEMPLATE_PACKAGE_LEGACY_TYPESCRIPT = + 'react-native-template-typescript'; + +// This version moved from inlining the template to using @react-native-community/template +export const TEMPLATE_COMMUNITY_REACT_NATIVE_VERSION = '0.75.0'; diff --git a/packages/cli/src/commands/init/init.ts b/packages/cli/src/commands/init/init.ts index f4da03370..824daf4c9 100644 --- a/packages/cli/src/commands/init/init.ts +++ b/packages/cli/src/commands/init/init.ts @@ -38,30 +38,11 @@ import { import semver from 'semver'; import {executeCommand} from '../../tools/executeCommand'; import DirectoryAlreadyExistsError from './errors/DirectoryAlreadyExistsError'; +import {createTemplateUri} from './version'; +import {TEMPLATE_COMMUNITY_REACT_NATIVE_VERSION} from './constants'; +import type {Options} from './types'; const DEFAULT_VERSION = 'latest'; -// This version moved from inlining the template to using @react-native-community/template -const TEMPLATE_COMMUNITY_REACT_NATIVE_VERSION = '0.75.0'; -const TEMPLATE_PACKAGE_COMMUNITY = '@react-native-community/template'; -const TEMPLATE_PACKAGE_LEGACY = 'react-native'; -const TEMPLATE_PACKAGE_LEGACY_TYPESCRIPT = 'react-native-template-typescript'; - -type Options = { - template?: string; - npm?: boolean; - pm?: PackageManager.PackageManager; - directory?: string; - displayName?: string; - title?: string; - skipInstall?: boolean; - version: string; - packageName?: string; - installPods?: string | boolean; - platformName?: string; - skipGitInit?: boolean; - replaceDirectory?: string | boolean; - yarnConfigOptions?: Record; -}; interface TemplateOptions { projectName: string; @@ -397,63 +378,6 @@ function checkPackageManagerAvailability( return false; } -async function createTemplateUri( - options: Options, - version: string, -): Promise { - if (options.platformName && options.platformName !== 'react-native') { - logger.debug('User has specified an out-of-tree platform, using it'); - return `${options.platformName}@${version}`; - } - - if (options.template === TEMPLATE_PACKAGE_LEGACY_TYPESCRIPT) { - logger.warn( - "Ignoring custom template: 'react-native-template-typescript'. Starting from React Native v0.71 TypeScript is used by default.", - ); - return TEMPLATE_PACKAGE_LEGACY; - } - - if (options.template) { - logger.debug(`Use the user provided --template=${options.template}`); - return options.template; - } - - // 0.75.0-nightly-20240618-5df5ed1a8' -> 0.75.0 - // 0.75.0-rc.1 -> 0.75.0 - const simpleVersion = semver.coerce(version) ?? version; - - // Does the react-native@version package *not* have a template embedded. We know that this applies to - // all version before 0.75. The 1st release candidate is the minimal version that has no template. - const useLegacyTemplate = semver.lt( - simpleVersion, - TEMPLATE_COMMUNITY_REACT_NATIVE_VERSION, - ); - - logger.debug( - `[template]: is '${version} (${simpleVersion})' < '${TEMPLATE_COMMUNITY_REACT_NATIVE_VERSION}' = ` + - (useLegacyTemplate - ? 'yes, look for template in react-native' - : 'no, look for template in @react-native-community/template'), - ); - - if (!useLegacyTemplate) { - if (/nightly/.test(version)) { - logger.debug( - "[template]: you're using a nightly version of react-native", - ); - // Template nightly versions and react-native@nightly versions don't match (template releases at a much - // lower cadence). We have to assume the user is running against the latest nightly by pointing to the tag. - return `${TEMPLATE_PACKAGE_COMMUNITY}@nightly`; - } - return `${TEMPLATE_PACKAGE_COMMUNITY}@${version}`; - } - - logger.debug( - `Using the legacy template because '${TEMPLATE_PACKAGE_LEGACY}' still contains a template folder`, - ); - return `${TEMPLATE_PACKAGE_LEGACY}@${version}`; -} - async function createProject( projectName: string, directory: string, diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts new file mode 100644 index 000000000..57ff1e54a --- /dev/null +++ b/packages/cli/src/commands/init/types.ts @@ -0,0 +1,18 @@ +import type {PackageManager} from '../../tools/packageManager'; + +export type Options = { + template?: string; + npm?: boolean; + pm?: PackageManager; + directory?: string; + displayName?: string; + title?: string; + skipInstall?: boolean; + version: string; + packageName?: string; + installPods?: string | boolean; + platformName?: string; + skipGitInit?: boolean; + replaceDirectory?: string | boolean; + yarnConfigOptions?: Record; +}; diff --git a/packages/cli/src/commands/init/version.ts b/packages/cli/src/commands/init/version.ts new file mode 100644 index 000000000..2fb8dfcaf --- /dev/null +++ b/packages/cli/src/commands/init/version.ts @@ -0,0 +1,69 @@ +import {logger} from '@react-native-community/cli-tools'; +import {getTemplateVersion} from '../../tools/npm'; +import semver from 'semver'; + +import type {Options} from './types'; +import { + TEMPLATE_COMMUNITY_REACT_NATIVE_VERSION, + TEMPLATE_PACKAGE_COMMUNITY, + TEMPLATE_PACKAGE_LEGACY, + TEMPLATE_PACKAGE_LEGACY_TYPESCRIPT, +} from './constants'; + +export async function createTemplateUri( + options: Options, + version: string, +): Promise { + if (options.platformName && options.platformName !== 'react-native') { + logger.debug('User has specified an out-of-tree platform, using it'); + return `${options.platformName}@${version}`; + } + + if (options.template === TEMPLATE_PACKAGE_LEGACY_TYPESCRIPT) { + logger.warn( + "Ignoring custom template: 'react-native-template-typescript'. Starting from React Native v0.71 TypeScript is used by default.", + ); + return TEMPLATE_PACKAGE_LEGACY; + } + + if (options.template) { + logger.debug(`Use the user provided --template=${options.template}`); + return options.template; + } + + // 0.75.0-nightly-20240618-5df5ed1a8' -> 0.75.0 + // 0.75.0-rc.1 -> 0.75.0 + const simpleVersion = semver.coerce(version) ?? version; + + // Does the react-native@version package *not* have a template embedded. We know that this applies to + // all version before 0.75. The 1st release candidate is the minimal version that has no template. + const useLegacyTemplate = semver.lt( + simpleVersion, + TEMPLATE_COMMUNITY_REACT_NATIVE_VERSION, + ); + + logger.debug( + `[template]: is '${version} (${simpleVersion})' < '${TEMPLATE_COMMUNITY_REACT_NATIVE_VERSION}' = ` + + (useLegacyTemplate + ? 'yes, look for template in react-native' + : 'no, look for template in @react-native-community/template'), + ); + + if (!useLegacyTemplate) { + if (/nightly/.test(version)) { + logger.debug( + "[template]: you're using a nightly version of react-native", + ); + // Template nightly versions and react-native@nightly versions don't match (template releases at a much + // lower cadence). We have to assume the user is running against the latest nightly by pointing to the tag. + return `${TEMPLATE_PACKAGE_COMMUNITY}@nightly`; + } + const templateVersion = await getTemplateVersion(version); + return `${TEMPLATE_PACKAGE_COMMUNITY}@${templateVersion}`; + } + + logger.debug( + `Using the legacy template because '${TEMPLATE_PACKAGE_LEGACY}' still contains a template folder`, + ); + return `${TEMPLATE_PACKAGE_LEGACY}@${version}`; +} diff --git a/packages/cli/src/tools/__tests__/npm-test.ts b/packages/cli/src/tools/__tests__/npm-test.ts new file mode 100644 index 000000000..c60315d5f --- /dev/null +++ b/packages/cli/src/tools/__tests__/npm-test.ts @@ -0,0 +1,126 @@ +import {getTemplateVersion} from '../npm'; +import assert from 'assert'; + +let ref: any; + +global.fetch = jest.fn(); + +function fetchReturn(json: any): void { + assert(global.fetch != null, 'You forgot to backup global.fetch!'); + // @ts-ignore + global.fetch = jest.fn(() => + Promise.resolve({json: () => Promise.resolve(json)}), + ); +} + +describe('getTemplateVersion', () => { + beforeEach(() => { + ref = global.fetch; + }); + afterEach(() => { + global.fetch = ref; + }); + + it('should order matching versions with the most recent first', async () => { + const VERSION = '0.75.1'; + fetchReturn({ + versions: { + '3.2.1': {scripts: {version: VERSION}}, + '1.0.0': {scripts: {version: '0.75.0'}}, + '1.2.3': {scripts: {version: VERSION}}, + }, + time: { + '3.2.1': '2024-08-15T00:00:00.000Z', + '1.0.0': '2024-08-15T10:10:10.000Z', + '1.2.3': '2024-08-16T00:00:00.000Z', // Last published version + }, + }); + + expect(await getTemplateVersion(VERSION)).toEqual('1.2.3'); + }); + + it('should matching latest MAJOR.MINOR if MAJOR.MINOR.PATCH has no match', async () => { + fetchReturn({ + versions: { + '3.2.1': {scripts: {version: '0.75.1'}}, + '3.2.2': {scripts: {version: '0.75.2'}}, + }, + time: { + '3.2.1': '2024-08-15T00:00:00.000Z', + '3.2.2': '2024-08-16T00:00:00.000Z', // Last published version + }, + }); + + expect(await getTemplateVersion('0.75.3')).toEqual('3.2.2'); + }); + + it('should NOT matching when MAJOR.MINOR is not found', async () => { + fetchReturn({ + versions: { + '3.2.1': {scripts: {version: '0.75.1'}}, + '3.2.2': {scripts: {version: '0.75.2'}}, + }, + time: { + '3.2.1': '2024-08-15T00:00:00.000Z', + '3.2.2': '2024-08-16T00:00:00.000Z', // Last published version + }, + }); + + expect(await getTemplateVersion('0.76.0')).toEqual(undefined); + }); + + it('ignores packages that have weird script version entries', async () => { + fetchReturn({ + versions: { + '1': {}, + '2': {scripts: {}}, + '3': {scripts: {version: 'echo "not a semver entry"'}}, + win: {scripts: {version: '0.75.2'}}, + }, + time: { + '1': '2024-08-14T00:00:00.000Z', + win: '2024-08-15T00:00:00.000Z', + // These would normally both beat '3' on time: + '2': '2024-08-16T00:00:00.000Z', + '3': '2024-08-16T00:00:00.000Z', + }, + }); + + expect(await getTemplateVersion('0.75.2')).toEqual('win'); + }); + + it('support `version` and `reactNativeVersion` entries from npm', async () => { + fetchReturn({ + versions: { + '3.2.1': {scripts: {version: '0.75.1'}}, + '3.2.2': {scripts: {reactNativeVersion: '0.75.2'}}, + }, + time: { + '3.2.1': '2024-08-15T00:00:00.000Z', + '3.2.2': '2024-08-16T00:00:00.000Z', // Last published version + }, + }); + + expect(await getTemplateVersion('0.75.2')).toEqual('3.2.2'); + }); + + it('prefers `reactNativeVersion` over `version` entries from npm', async () => { + fetchReturn({ + versions: { + '3.2.1': {scripts: {version: '0.75.1'}}, + '3.2.2': { + scripts: { + reactNativeVersion: '0.75.2', + version: 'should prefer the other one', + }, + }, + }, + time: { + '3.2.1': '2024-08-15T00:00:00.000Z', + '3.2.2': '2024-08-16T00:00:00.000Z', // Last published version + }, + }); + + expect(await getTemplateVersion('0.75.2')).toEqual('3.2.2'); + }); +}); diff --git a/packages/cli/src/tools/npm.ts b/packages/cli/src/tools/npm.ts index 90c159bc0..c090571f1 100644 --- a/packages/cli/src/tools/npm.ts +++ b/packages/cli/src/tools/npm.ts @@ -8,6 +8,7 @@ import {execSync} from 'child_process'; import findUp from 'find-up'; +import semver from 'semver'; export function getNpmVersionIfAvailable() { let npmVersion; @@ -76,3 +77,111 @@ export async function npmResolveConcreteVersion( const json: any = await resp.json(); return json.version; } + +type TimeStampString = string; +type TemplateVersion = string; +type VersionedTemplates = { + [rnVersion: string]: Template[]; +}; + +type NpmTemplateResponse = { + versions: { + // Template version, semver including -rc candidates + [version: TemplateVersion]: { + scripts?: { + // Version of react-native this is built for + reactNativeVersion?: string; + // The initial implemntation used this, but moved to reactNativeVersion + version?: string; + }; + }; + }; + time: { + created: string; + modified: string; + [version: TemplateVersion]: TimeStampString; + }; +}; + +class Template { + version: string; + reactNativeVersion: string; + published: Date; + + constructor(version: string, reactNativeVersion: string, published: string) { + this.version = version; + this.reactNativeVersion = reactNativeVersion; + this.published = new Date(published); + } + + get isPreRelease() { + return this.version.includes('-rc'); + } +} + +const TEMPLATE_VERSIONS_URL = + 'https://registry.npmjs.org/@react-native-community/template'; +const minorVersion = (version: string) => { + const v = semver.parse(version)!; + return `${v.major}.${v.minor}`; +}; + +export async function getTemplateVersion( + reactNativeVersion: string, +): Promise { + const json = await fetch(TEMPLATE_VERSIONS_URL).then( + (resp) => resp.json() as Promise, + ); + + // We are abusing which npm metadata is publicly available through the registry. Scripts + // is always captured, and we use this in the Github Action that manages our releases to + // capture the version of React Native the template is built with. + // + // Users are interested in: + // - IF there a match for React Native MAJOR.MINOR.PATCH? + // - Yes: if there are >= 2 versions, pick the one last published. This lets us release + // specific fixes for React Native versions. + // - ELSE, is there a match for React Native MINOR.PATCH? + // - Yes: if there are >= 2 versions, pick the one last published. This decouples us from + // React Native releases. + // - No: we don't have a version of the template for a version of React Native. There should + // at a minimum be at last one version cut for each MINOR.PATCH since 0.75. Before this + // the template was shipped with React Native + const rnToTemplate: VersionedTemplates = {}; + for (const [templateVersion, pkg] of Object.entries(json.versions)) { + const rnVersion = pkg?.scripts?.reactNativeVersion ?? pkg?.scripts?.version; + if (rnVersion == null || !semver.valid(rnVersion)) { + // This is a very early version that doesn't have the correct metadata embedded + continue; + } + + const template = new Template( + templateVersion, + rnVersion, + json.time[templateVersion], + ); + + const rnMinorVersion = minorVersion(rnVersion); + + rnToTemplate[rnVersion] = rnToTemplate[rnVersion] ?? []; + rnToTemplate[rnVersion].push(template); + rnToTemplate[rnMinorVersion] = rnToTemplate[rnMinorVersion] ?? []; + rnToTemplate[rnMinorVersion].push(template); + } + + // Make sure the last published is the first one in each version of React Native + for (const v in rnToTemplate) { + rnToTemplate[v].sort( + (a, b) => b.published.getTime() - a.published.getTime(), + ); + } + + if (reactNativeVersion in rnToTemplate) { + return rnToTemplate[reactNativeVersion][0].version; + } + const rnMinorVersion = minorVersion(reactNativeVersion); + if (rnMinorVersion in rnToTemplate) { + return rnToTemplate[rnMinorVersion][0].version; + } + return; +}