Skip to content

Commit

Permalink
feat: determine react-native → template from npm registry data (#2475)
Browse files Browse the repository at this point in the history
  • Loading branch information
blakef committed Sep 2, 2024
1 parent 4573eca commit e3bc0dd
Show file tree
Hide file tree
Showing 7 changed files with 373 additions and 79 deletions.
41 changes: 41 additions & 0 deletions packages/cli/src/commands/init/__tests__/version.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
7 changes: 7 additions & 0 deletions packages/cli/src/commands/init/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
82 changes: 3 additions & 79 deletions packages/cli/src/commands/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
};

interface TemplateOptions {
projectName: string;
Expand Down Expand Up @@ -397,63 +378,6 @@ function checkPackageManagerAvailability(
return false;
}

async function createTemplateUri(
options: Options,
version: string,
): Promise<string> {
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,
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/init/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};
69 changes: 69 additions & 0 deletions packages/cli/src/commands/init/version.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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}`;
}
126 changes: 126 additions & 0 deletions packages/cli/src/tools/__tests__/npm-test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading

0 comments on commit e3bc0dd

Please sign in to comment.