diff --git a/packages/@vue/cli-plugin-typescript/__tests__/tsGenerator.spec.js b/packages/@vue/cli-plugin-typescript/__tests__/tsGenerator.spec.js index a745786439..f2fd642900 100644 --- a/packages/@vue/cli-plugin-typescript/__tests__/tsGenerator.spec.js +++ b/packages/@vue/cli-plugin-typescript/__tests__/tsGenerator.spec.js @@ -65,19 +65,34 @@ test('use with Babel', async () => { }) test('use with router', async () => { + const tsApply = require('../generator') + + expect(tsApply.after).toBe('@vue/cli-plugin-router') + const { files } = await generateWithPlugin([ { - id: '@vue/cli-plugin-router', - apply: require('@vue/cli-plugin-router/generator'), + id: '@vue/cli-service', + apply: require('@vue/cli-service/generator'), + options: { + plugins: { + '@vue/cli-service': {}, + '@vue/cli-plugin-router': {}, + '@vue/cli-plugin-typescript': {} + } + } + }, + { + id: '@vue/cli-plugin-typescript', + apply: tsApply, options: {} }, { - id: 'ts', - apply: require('../generator'), + id: '@vue/cli-plugin-router', + apply: require('@vue/cli-plugin-router/generator'), options: {} } ]) - expect(files['src/views/Home.vue']).toMatch('
') + expect(files['src/views/Home.vue']).toMatch('Welcome to Your Vue.js + TypeScript App') }) test('tsconfig.json should be valid json', async () => { diff --git a/packages/@vue/cli-plugin-typescript/generator/index.js b/packages/@vue/cli-plugin-typescript/generator/index.js index dc776d10b8..2c86584e18 100644 --- a/packages/@vue/cli-plugin-typescript/generator/index.js +++ b/packages/@vue/cli-plugin-typescript/generator/index.js @@ -71,3 +71,5 @@ module.exports = ( require('./convert')(api, { convertJsToTs }) } + +module.exports.after = '@vue/cli-plugin-router' diff --git a/packages/@vue/cli-service/__tests__/Service.spec.js b/packages/@vue/cli-service/__tests__/Service.spec.js index 6fca99358a..b593b7936c 100644 --- a/packages/@vue/cli-service/__tests__/Service.spec.js +++ b/packages/@vue/cli-service/__tests__/Service.spec.js @@ -381,3 +381,50 @@ test('api: hasPlugin', async () => { } ]) }) + +test('order: service plugins order', async () => { + const applyCallOrder = [] + function apply (id, order) { + order = order || {} + const fn = jest.fn(() => { applyCallOrder.push(id) }) + fn.after = order.after + return fn + } + const service = new Service('/', { + plugins: [ + { + id: 'vue-cli-plugin-foo', + apply: apply('vue-cli-plugin-foo') + }, + { + id: 'vue-cli-plugin-bar', + apply: apply('vue-cli-plugin-bar', { after: 'vue-cli-plugin-baz' }) + }, + { + id: 'vue-cli-plugin-baz', + apply: apply('vue-cli-plugin-baz') + } + ] + }) + expect(service.plugins.map(p => p.id)).toEqual([ + 'built-in:commands/serve', + 'built-in:commands/build', + 'built-in:commands/inspect', + 'built-in:commands/help', + 'built-in:config/base', + 'built-in:config/assets', + 'built-in:config/css', + 'built-in:config/prod', + 'built-in:config/app', + 'vue-cli-plugin-foo', + 'vue-cli-plugin-baz', + 'vue-cli-plugin-bar' + ]) + + await service.init() + expect(applyCallOrder).toEqual([ + 'vue-cli-plugin-foo', + 'vue-cli-plugin-baz', + 'vue-cli-plugin-bar' + ]) +}) diff --git a/packages/@vue/cli-service/lib/Service.js b/packages/@vue/cli-service/lib/Service.js index 5974a8cd1a..82d6c58aaa 100644 --- a/packages/@vue/cli-service/lib/Service.js +++ b/packages/@vue/cli-service/lib/Service.js @@ -6,7 +6,7 @@ const PluginAPI = require('./PluginAPI') const dotenv = require('dotenv') const dotenvExpand = require('dotenv-expand') const defaultsDeep = require('lodash.defaultsdeep') -const { warn, error, isPlugin, resolvePluginId, loadModule, resolvePkg, resolveModule } = require('@vue/cli-shared-utils') +const { warn, error, isPlugin, resolvePluginId, loadModule, resolvePkg, resolveModule, sortPlugins } = require('@vue/cli-shared-utils') const { defaults } = require('./options') const checkWebpack = require('./util/checkWebpack') @@ -224,8 +224,12 @@ module.exports = class Service { apply: loadModule(`./${file}`, this.pkgContext) }))) } + debug('vue:plugins')(plugins) - return plugins + const orderedPlugins = sortPlugins(plugins) + debug('vue:plugins-ordered')(orderedPlugins) + + return orderedPlugins } async run (name, args = {}, rawArgv = []) { diff --git a/packages/@vue/cli-shared-utils/__tests__/pluginOrder.spec.js b/packages/@vue/cli-shared-utils/__tests__/pluginOrder.spec.js new file mode 100644 index 0000000000..0adc0638b9 --- /dev/null +++ b/packages/@vue/cli-shared-utils/__tests__/pluginOrder.spec.js @@ -0,0 +1,73 @@ +const { topologicalSorting } = require('../lib/pluginOrder') +const { logs } = require('../lib/logger') + +/** + * + * @param {string} id + * @param {{stage: number, after: string|Array}} [order] + */ +function plugin (id, order) { + order = order || {} + const { after } = order + + // use object instead of function here + const apply = {} + apply.after = after + return { + id, + apply + } +} + +describe('topologicalSorting', () => { + test(`no specifying 'after' will preserve sort order`, () => { + const plugins = [ + plugin('foo'), + plugin('bar'), + plugin('baz') + ] + const orderPlugins = topologicalSorting(plugins) + expect(orderPlugins).toEqual(plugins) + }) + + test(`'after' specified`, () => { + const plugins = [ + plugin('foo', { after: 'bar' }), + plugin('bar', { after: 'baz' }), + plugin('baz') + ] + const orderPlugins = topologicalSorting(plugins) + expect(orderPlugins).toEqual([ + plugin('baz'), + plugin('bar', { after: 'baz' }), + plugin('foo', { after: 'bar' }) + ]) + }) + + test(`'after' can be Array`, () => { + const plugins = [ + plugin('foo', { after: ['bar', 'baz'] }), + plugin('bar'), + plugin('baz') + ] + const orderPlugins = topologicalSorting(plugins) + expect(orderPlugins).toEqual([ + plugin('bar'), + plugin('baz'), + plugin('foo', { after: ['bar', 'baz'] }) + ]) + }) + + test('it is not possible to sort plugins because of cyclic graph, return original plugins directly', () => { + logs.warn = [] + const plugins = [ + plugin('foo', { after: 'bar' }), + plugin('bar', { after: 'baz' }), + plugin('baz', { after: 'foo' }) + ] + const orderPlugins = topologicalSorting(plugins) + expect(orderPlugins).toEqual(plugins) + + expect(logs.warn.length).toBe(1) + }) +}) diff --git a/packages/@vue/cli-shared-utils/index.js b/packages/@vue/cli-shared-utils/index.js index 830a325459..270ca0e579 100644 --- a/packages/@vue/cli-shared-utils/index.js +++ b/packages/@vue/cli-shared-utils/index.js @@ -8,6 +8,7 @@ 'openBrowser', 'pkg', 'pluginResolution', + 'pluginOrder', 'launch', 'request', 'spinner', diff --git a/packages/@vue/cli-shared-utils/lib/pluginOrder.js b/packages/@vue/cli-shared-utils/lib/pluginOrder.js new file mode 100644 index 0000000000..8d1043cd4f --- /dev/null +++ b/packages/@vue/cli-shared-utils/lib/pluginOrder.js @@ -0,0 +1,110 @@ +// @ts-check +const { warn } = require('./logger') + +/** @typedef {{after?: string|Array}} Apply */ +/** @typedef {{id: string, apply: Apply}} Plugin */ +/** @typedef {{after: Set}} OrderParams */ + +/** @type {Map} */ +const orderParamsCache = new Map() + +/** + * + * @param {Plugin} plugin + * @returns {OrderParams} + */ +function getOrderParams (plugin) { + if (!process.env.VUE_CLI_TEST && orderParamsCache.has(plugin.id)) { + return orderParamsCache.get(plugin.id) + } + const apply = plugin.apply + + let after = new Set() + if (typeof apply.after === 'string') { + after = new Set([apply.after]) + } else if (Array.isArray(apply.after)) { + after = new Set(apply.after) + } + if (!process.env.VUE_CLI_TEST) { + orderParamsCache.set(plugin.id, { after }) + } + + return { after } +} + +/** + * See leetcode 210 + * @param {Array} plugins + * @returns {Array} + */ +function topologicalSorting (plugins) { + /** @type {Map} */ + const pluginsMap = new Map(plugins.map(p => [p.id, p])) + + /** @type {Map} */ + const indegrees = new Map() + + /** @type {Map>} */ + const graph = new Map() + + plugins.forEach(p => { + const after = getOrderParams(p).after + indegrees.set(p, after.size) + if (after.size === 0) return + for (const id of after) { + const prerequisite = pluginsMap.get(id) + // remove invalid data + if (!prerequisite) { + indegrees.set(p, indegrees.get(p) - 1) + continue + } + + if (!graph.has(prerequisite)) { + graph.set(prerequisite, []) + } + graph.get(prerequisite).push(p) + } + }) + + const res = [] + const queue = [] + indegrees.forEach((d, p) => { + if (d === 0) queue.push(p) + }) + while (queue.length) { + const cur = queue.shift() + res.push(cur) + const neighbors = graph.get(cur) + if (!neighbors) continue + + neighbors.forEach(n => { + const degree = indegrees.get(n) - 1 + indegrees.set(n, degree) + if (degree === 0) { + queue.push(n) + } + }) + } + const valid = res.length === plugins.length + if (!valid) { + warn(`No proper plugin execution order found.`) + return plugins + } + return res +} + +/** + * Arrange plugins by 'after' property. + * @param {Array} plugins + * @returns {Array} + */ +function sortPlugins (plugins) { + if (plugins.length < 2) return plugins + + return topologicalSorting(plugins) +} + +module.exports = { + topologicalSorting, + sortPlugins +} diff --git a/packages/@vue/cli/__tests__/Generator.spec.js b/packages/@vue/cli/__tests__/Generator.spec.js index a7f4334574..0183d9f54e 100644 --- a/packages/@vue/cli/__tests__/Generator.spec.js +++ b/packages/@vue/cli/__tests__/Generator.spec.js @@ -1167,3 +1167,92 @@ test('run a codemod on the entry file', async () => { await generator.generate() expect(fs.readFileSync('/main.js', 'utf-8')).toMatch(/new TestVue/) }) + +test('order: generator plugins order', async () => { + const applyCallOrder = [] + function apply (id, order) { + order = order || {} + const fn = jest.fn(() => { applyCallOrder.push(id) }) + fn.after = order.after + return fn + } + const generator = new Generator('/', { + plugins: [ + { + id: 'vue-cli-plugin-foo', + apply: apply('vue-cli-plugin-foo') + }, + { + id: 'vue-cli-plugin-bar', + apply: apply('vue-cli-plugin-bar', { after: 'vue-cli-plugin-baz' }) + }, + { + id: 'vue-cli-plugin-baz', + apply: apply('vue-cli-plugin-baz') + } + ] + }) + await generator.generate() + + expect(applyCallOrder).toEqual([ + 'vue-cli-plugin-foo', + 'vue-cli-plugin-baz', + 'vue-cli-plugin-bar' + ]) +}) + +test('order: afterAnyInvoke order', async () => { + const fooAnyInvokeHandler = () => {} + const barAnyInvokeHandler = () => {} + const bazAnyInvokeHandler = () => {} + + const getGeneratorFn = (anyInvokeHandler, order) => { + order = order || {} + const generatorFn = () => {} + generatorFn.hooks = api => { + api.afterAnyInvoke(anyInvokeHandler) + } + generatorFn.after = order.after + return generatorFn + } + + jest.doMock('vue-cli-plugin-foo-order/generator', () => { + return getGeneratorFn(fooAnyInvokeHandler, { after: 'vue-cli-plugin-bar-order' }) + }, { virtual: true }) + + jest.doMock('vue-cli-plugin-bar-order/generator', () => { + return getGeneratorFn(barAnyInvokeHandler) + }, { virtual: true }) + + jest.doMock('vue-cli-plugin-baz-order/generator', () => { + return getGeneratorFn(bazAnyInvokeHandler) + }, { virtual: true }) + + const afterAnyInvokeCbs = [] + const afterInvokeCbs = [] + const generator = new Generator('/', { + pkg: { + devDependencies: { + 'vue-cli-plugin-foo-order': '1.0.0', + 'vue-cli-plugin-bar-order': '1.0.0', + 'vue-cli-plugin-baz-order': '1.0.0' + } + }, + plugins: [ + { + id: 'vue-cli-plugin-foo-order', + apply: getGeneratorFn(fooAnyInvokeHandler) + } + ], + afterInvokeCbs, + afterAnyInvokeCbs + }) + + await generator.generate() + + expect(afterAnyInvokeCbs).toEqual([ + barAnyInvokeHandler, + bazAnyInvokeHandler, + fooAnyInvokeHandler + ]) +}) diff --git a/packages/@vue/cli/lib/Creator.js b/packages/@vue/cli/lib/Creator.js index 925bdb9f6d..4931cbf869 100644 --- a/packages/@vue/cli/lib/Creator.js +++ b/packages/@vue/cli/lib/Creator.js @@ -111,16 +111,6 @@ module.exports = class Creator extends EventEmitter { } } - // Introducing this hack because typescript plugin must be invoked after router. - // Currently we rely on the `plugins` object enumeration order, - // which depends on the order of the field initialization. - // FIXME: Remove this ugly hack after the plugin ordering API settled down - if (preset.plugins['@vue/cli-plugin-router'] && preset.plugins['@vue/cli-plugin-typescript']) { - const tmp = preset.plugins['@vue/cli-plugin-typescript'] - delete preset.plugins['@vue/cli-plugin-typescript'] - preset.plugins['@vue/cli-plugin-typescript'] = tmp - } - // legacy support for vuex if (preset.vuex) { preset.plugins['@vue/cli-plugin-vuex'] = {} diff --git a/packages/@vue/cli/lib/Generator.js b/packages/@vue/cli/lib/Generator.js index c8f1a22c5c..5b26ee78cc 100644 --- a/packages/@vue/cli/lib/Generator.js +++ b/packages/@vue/cli/lib/Generator.js @@ -14,7 +14,9 @@ const { toShortPluginId, matchesPluginId, - loadModule + loadModule, + + sortPlugins } = require('@vue/cli-shared-utils') const ConfigTransform = require('./ConfigTransform') @@ -109,7 +111,7 @@ module.exports = class Generator { invoking = false } = {}) { this.context = context - this.plugins = plugins + this.plugins = sortPlugins(plugins) this.originalPkg = pkg this.pkg = Object.assign({}, pkg) this.pm = new PackageManager({ context }) @@ -135,9 +137,7 @@ module.exports = class Generator { this.exitLogs = [] // load all the other plugins - this.allPluginIds = Object.keys(this.pkg.dependencies || {}) - .concat(Object.keys(this.pkg.devDependencies || {})) - .filter(isPlugin) + this.allPlugins = this.resolveAllPlugins() const cliService = plugins.find(p => p.id === '@vue/cli-service') const rootOptions = cliService @@ -155,12 +155,12 @@ module.exports = class Generator { const passedAfterInvokeCbs = this.afterInvokeCbs this.afterInvokeCbs = [] // apply hooks from all plugins to collect 'afterAnyHooks' - for (const id of this.allPluginIds) { + for (const plugin of this.allPlugins) { + const { id, apply } = plugin const api = new GeneratorAPI(id, this, {}, rootOptions) - const pluginGenerator = loadModule(`${id}/generator`, this.context) - if (pluginGenerator && pluginGenerator.hooks) { - await pluginGenerator.hooks(api, {}, rootOptions, pluginIds) + if (apply.hooks) { + await apply.hooks(api, {}, rootOptions, pluginIds) } } @@ -182,7 +182,7 @@ module.exports = class Generator { if (apply.hooks) { // while we execute the entire `hooks` function, // only the `afterInvoke` hook is respected - // because `afterAnyHooks` is already determined by the `allPluginIds` loop above + // because `afterAnyHooks` is already determined by the `allPlugins` loop above await apply.hooks(api, options, rootOptions, pluginIds) } } @@ -291,6 +291,19 @@ module.exports = class Generator { debug('vue:cli-pkg')(this.pkg) } + resolveAllPlugins () { + const allPlugins = [] + Object.keys(this.pkg.dependencies || {}) + .concat(Object.keys(this.pkg.devDependencies || {})) + .forEach(id => { + if (!isPlugin(id)) return + const pluginGenerator = loadModule(`${id}/generator`, this.context) + if (!pluginGenerator) return + allPlugins.push({ id, apply: pluginGenerator }) + }) + return sortPlugins(allPlugins) + } + async resolveFiles () { const files = this.files for (const middleware of this.fileMiddlewares) { @@ -333,7 +346,7 @@ module.exports = class Generator { hasPlugin (id, versionRange) { const pluginExists = [ ...this.plugins.map(p => p.id), - ...this.allPluginIds + ...this.allPlugins.map(p => p.id) ].some(pid => matchesPluginId(id, pid)) if (!pluginExists) {