Merge pull request #32700 from backstage/rugvip/new-frontend-system-default

Make the new frontend system the default for new apps
This commit is contained in:
Patrik Oldsberg
2026-03-17 21:24:11 +01:00
committed by GitHub
55 changed files with 333 additions and 170 deletions
@@ -16,6 +16,8 @@
export const defaultTemplates = [
'@backstage/cli-module-new/templates/frontend-plugin',
'@backstage/cli-module-new/templates/frontend-plugin-module',
'@backstage/cli-module-new/templates/legacy-frontend-plugin',
'@backstage/cli-module-new/templates/backend-plugin',
'@backstage/cli-module-new/templates/backend-plugin-module',
'@backstage/cli-module-new/templates/plugin-web-library',
@@ -360,6 +360,141 @@ describe('loadPortableTemplateConfig', () => {
);
});
it('should filter out legacy frontend template for new frontend system apps', async () => {
mockDir.setContent({
'package.json': JSON.stringify({}),
packages: {
app: {
'package.json': JSON.stringify({
dependencies: {
'@backstage/frontend-defaults': '^0.1.0',
},
}),
},
},
node_modules: Object.fromEntries(
defaultTemplates.map(t => {
// Match the real behavior: both frontend-plugin and legacy-frontend-plugin
// have the same template name "frontend-plugin"
const name = t.endsWith('/legacy-frontend-plugin')
? 'frontend-plugin'
: basename(t);
return [
t,
{ [TEMPLATE_FILE_NAME]: `name: ${name}\nrole: web-library\n` },
];
}),
),
});
const config = await loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
});
expect(config.isUsingDefaultTemplates).toBe(true);
const templateNames = config.templatePointers.map(t => t.name);
expect(templateNames).toContain('frontend-plugin');
expect(templateNames).toContain('frontend-plugin-module');
expect(templateNames).toContain('backend-plugin');
// Legacy template should be filtered out
expect(templateNames).not.toContain('legacy-frontend-plugin');
// The frontend-plugin in the list should be from the new template, not legacy
const frontendPlugin = config.templatePointers.find(
t => t.name === 'frontend-plugin',
);
expect(frontendPlugin?.target).toContain('/frontend-plugin/');
expect(frontendPlugin?.target).not.toContain('/legacy-frontend-plugin/');
});
it('should filter out new frontend templates for legacy frontend system apps', async () => {
mockDir.setContent({
'package.json': JSON.stringify({}),
packages: {
app: {
'package.json': JSON.stringify({
dependencies: {
'@backstage/app-defaults': '^0.1.0',
'@backstage/core-app-api': '^0.1.0',
},
}),
},
},
node_modules: Object.fromEntries(
defaultTemplates.map(t => {
const name = t.endsWith('/legacy-frontend-plugin')
? 'frontend-plugin'
: basename(t);
return [
t,
{ [TEMPLATE_FILE_NAME]: `name: ${name}\nrole: web-library\n` },
];
}),
),
});
const config = await loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
});
expect(config.isUsingDefaultTemplates).toBe(true);
const templateNames = config.templatePointers.map(t => t.name);
// Legacy template should be present (shown as "frontend-plugin")
expect(templateNames).toContain('frontend-plugin');
expect(templateNames).toContain('backend-plugin');
// New frontend templates should be filtered out
expect(templateNames).not.toContain('frontend-plugin-module');
// The frontend-plugin in the list should be from the legacy template
const frontendPlugin = config.templatePointers.find(
t => t.name === 'frontend-plugin',
);
expect(frontendPlugin?.target).toContain('/legacy-frontend-plugin/');
});
it('should not filter templates when using explicit configuration', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
backstage: {
cli: {
new: {
templates: ['./my-frontend-plugin', './my-backend-plugin'],
},
},
},
}),
// Even with a new frontend system app, explicit templates aren't filtered
packages: {
app: {
'package.json': JSON.stringify({
dependencies: {
'@backstage/frontend-defaults': '^0.1.0',
},
}),
},
},
'my-frontend-plugin': {
[TEMPLATE_FILE_NAME]: 'name: frontend-plugin\nrole: frontend-plugin\n',
},
'my-backend-plugin': {
[TEMPLATE_FILE_NAME]: 'name: backend-plugin\nrole: backend-plugin\n',
},
});
const config = await loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
});
expect(config.isUsingDefaultTemplates).toBe(false);
expect(config.templatePointers).toHaveLength(2);
expect(config.templatePointers.map(t => t.name)).toEqual([
'frontend-plugin',
'backend-plugin',
]);
});
it('should handle missing backstage.new configuration', async () => {
mockDir.setContent({
'package.json': JSON.stringify({}),
@@ -15,9 +15,8 @@
*/
import fs from 'fs-extra';
import { resolve as resolvePath, dirname, isAbsolute } from 'node:path';
import { resolve as resolvePath, dirname, isAbsolute, join } from 'node:path';
import { targetPaths } from '@backstage/cli-common';
import { defaultTemplates } from '../defaultTemplates';
import {
PortableTemplateConfig,
@@ -29,6 +28,60 @@ import { z } from 'zod/v3';
import { fromZodError } from 'zod-validation-error/v3';
import { ForwardedError } from '@backstage/errors';
type FrontendSystem = 'new' | 'legacy' | 'unknown';
async function detectFrontendSystem(basePath: string): Promise<FrontendSystem> {
const appPkgPath = join(basePath, 'packages', 'app', 'package.json');
try {
const appPkgJson = await fs.readJson(appPkgPath);
const deps = {
...appPkgJson.dependencies,
...appPkgJson.devDependencies,
};
if (
deps['@backstage/frontend-defaults'] ||
deps['@backstage/frontend-app-api']
) {
return 'new';
}
if (deps['@backstage/app-defaults'] || deps['@backstage/core-app-api']) {
return 'legacy';
}
} catch {
// App package doesn't exist or can't be read
}
return 'unknown';
}
// Templates to exclude based on frontend system detection (by path, not name)
const newFrontendTemplates = [
'@backstage/cli-module-new/templates/frontend-plugin',
'@backstage/cli-module-new/templates/frontend-plugin-module',
];
const legacyFrontendTemplates = [
'@backstage/cli-module-new/templates/legacy-frontend-plugin',
];
function filterTemplateEntriesForFrontendSystem(
entries: Array<{ pointer: PortableTemplatePointer; rawPointer: string }>,
frontendSystem: FrontendSystem,
): Array<{ pointer: PortableTemplatePointer; rawPointer: string }> {
if (frontendSystem === 'unknown') {
return entries;
}
if (frontendSystem === 'new') {
// Filter out legacy frontend templates
return entries.filter(e => !legacyFrontendTemplates.includes(e.rawPointer));
}
// Legacy system - filter out new frontend templates
return entries.filter(e => !newFrontendTemplates.includes(e.rawPointer));
}
const defaults = {
license: 'Apache-2.0',
version: '0.1.0',
@@ -105,7 +158,9 @@ export async function loadPortableTemplateConfig(
const config = parsed.data.backstage?.cli?.new;
const basePath = dirname(pkgPath);
const templatePointerEntries = await Promise.all(
const isUsingDefaultTemplates = !config?.templates;
let templatePointerEntries = await Promise.all(
(config?.templates ?? defaultTemplates).map(async rawPointer => {
try {
const templatePath = resolveLocalTemplatePath(rawPointer, basePath);
@@ -121,6 +176,17 @@ export async function loadPortableTemplateConfig(
}),
);
// Auto-filter frontend templates based on detected frontend system.
// This must happen before the conflict check since both the new and legacy
// frontend plugin templates have the same name, but only one will be shown.
if (isUsingDefaultTemplates) {
const frontendSystem = await detectFrontendSystem(basePath);
templatePointerEntries = filterTemplateEntriesForFrontendSystem(
templatePointerEntries,
frontendSystem,
);
}
const templateNameConflicts = new Map<string, string>();
for (const { pointer, rawPointer } of templatePointerEntries) {
const conflict = templateNameConflicts.get(pointer.name);
@@ -143,7 +209,7 @@ export async function loadPortableTemplateConfig(
);
return {
isUsingDefaultTemplates: !config?.templates,
isUsingDefaultTemplates,
templatePointers: templatePointerEntries.map(({ pointer }) => pointer),
license: overrides.license ?? config?.globals?.license ?? defaults.license,
version: overrides.version ?? config?.globals?.version ?? defaults.version,
@@ -6,7 +6,14 @@ _This plugin was created through the Backstage CLI_
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/{{pluginId}}](http://localhost:3000/{{pluginId}}).
Your plugin has been added to the app in this repository, meaning you'll be able
to access it by running `yarn start` in the root directory, and then navigating
to [/{{pluginId}}](http://localhost:3000/{{pluginId}}).
This plugin is built with Backstage's [new frontend
system](https://backstage.io/docs/frontend-system/architecture/index), and you
can find more information about building plugins in the [plugin builder
documentation](https://backstage.io/docs/frontend-system/building-plugins/index).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
@@ -23,7 +23,7 @@
},
"dependencies": {
"@backstage/core-components": "{{versionQuery '@backstage/core-components'}}",
"@backstage/core-plugin-api": "{{versionQuery '@backstage/core-plugin-api'}}",
"@backstage/frontend-plugin-api": "{{versionQuery '@backstage/frontend-plugin-api'}}",
"@backstage/theme": "{{versionQuery '@backstage/theme'}}",
"@material-ui/core": "{{versionQuery '@material-ui/core' '4.12.2'}}",
"@material-ui/icons": "{{versionQuery '@material-ui/icons' '4.9.1'}}",
@@ -31,22 +31,18 @@
"react-use": "{{versionQuery 'react-use' '17.2.4'}}"
},
"peerDependencies": {
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-dom": "{{versionQuery 'react-dom' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-router-dom": "{{versionQuery 'react-router-dom' '^6.0.0'}}"
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@backstage/core-app-api": "{{versionQuery '@backstage/core-app-api'}}",
"@backstage/dev-utils": "{{versionQuery '@backstage/dev-utils'}}",
"@backstage/test-utils": "{{versionQuery '@backstage/test-utils'}}",
"@backstage/frontend-dev-utils": "{{versionQuery '@backstage/frontend-dev-utils'}}",
"@backstage/frontend-defaults": "{{versionQuery '@backstage/frontend-defaults'}}",
"@backstage/frontend-test-utils": "{{versionQuery '@backstage/frontend-test-utils'}}",
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}",
"@testing-library/react": "{{versionQuery '@testing-library/react' '14.0.0'}}",
"@testing-library/user-event": "{{versionQuery '@testing-library/user-event' '14.0.0'}}",
"msw": "{{versionQuery 'msw' '1.0.0'}}",
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-dom": "{{versionQuery 'react-dom' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-router-dom": "{{versionQuery 'react-router-dom' '^6.0.0'}}"
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}"
},
"files": [
"dist"
@@ -3,4 +3,3 @@ role: frontend-plugin
description: A new frontend plugin
values:
pluginVar: '{{ camelCase pluginId }}Plugin'
extensionName: '{{ upperFirst ( camelCase pluginId ) }}Page'
@@ -5,7 +5,7 @@ import { screen } from '@testing-library/react';
import {
registerMswTestHooks,
renderInTestApp,
} from '@backstage/test-utils';
} from '@backstage/frontend-test-utils';
describe('ExampleComponent', () => {
const server = setupServer();
@@ -1,4 +1,4 @@
import { renderInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/frontend-test-utils';
import { ExampleFetchComponent } from './ExampleFetchComponent';
describe('ExampleFetchComponent', () => {
@@ -1 +1 @@
export { {{ pluginVar }}, {{ extensionName }} } from './plugin';
export { {{ pluginVar }} as default } from './plugin';
@@ -0,0 +1,13 @@
# {{pluginId}}
Welcome to the {{pluginId}} plugin!
_This plugin was created through the Backstage CLI_
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/{{pluginId}}](http://localhost:3000/{{pluginId}}).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
@@ -23,7 +23,7 @@
},
"dependencies": {
"@backstage/core-components": "{{versionQuery '@backstage/core-components'}}",
"@backstage/frontend-plugin-api": "{{versionQuery '@backstage/frontend-plugin-api'}}",
"@backstage/core-plugin-api": "{{versionQuery '@backstage/core-plugin-api'}}",
"@backstage/theme": "{{versionQuery '@backstage/theme'}}",
"@material-ui/core": "{{versionQuery '@material-ui/core' '4.12.2'}}",
"@material-ui/icons": "{{versionQuery '@material-ui/icons' '4.9.1'}}",
@@ -31,17 +31,22 @@
"react-use": "{{versionQuery 'react-use' '17.2.4'}}"
},
"peerDependencies": {
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}"
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-dom": "{{versionQuery 'react-dom' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-router-dom": "{{versionQuery 'react-router-dom' '^6.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@backstage/frontend-dev-utils": "{{versionQuery '@backstage/frontend-dev-utils'}}",
"@backstage/frontend-test-utils": "{{versionQuery '@backstage/frontend-test-utils'}}",
"@backstage/core-app-api": "{{versionQuery '@backstage/core-app-api'}}",
"@backstage/dev-utils": "{{versionQuery '@backstage/dev-utils'}}",
"@backstage/test-utils": "{{versionQuery '@backstage/test-utils'}}",
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}",
"@testing-library/react": "{{versionQuery '@testing-library/react' '14.0.0'}}",
"@testing-library/user-event": "{{versionQuery '@testing-library/user-event' '14.0.0'}}",
"msw": "{{versionQuery 'msw' '1.0.0'}}",
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}"
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-dom": "{{versionQuery 'react-dom' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-router-dom": "{{versionQuery 'react-router-dom' '^6.0.0'}}"
},
"files": [
"dist"
@@ -0,0 +1,6 @@
name: frontend-plugin
role: frontend-plugin
description: A new frontend plugin (legacy system)
values:
pluginVar: '{{ camelCase pluginId }}Plugin'
extensionName: '{{ upperFirst ( camelCase pluginId ) }}Page'
@@ -5,7 +5,7 @@ import { screen } from '@testing-library/react';
import {
registerMswTestHooks,
renderInTestApp,
} from '@backstage/frontend-test-utils';
} from '@backstage/test-utils';
describe('ExampleComponent', () => {
const server = setupServer();
@@ -1,4 +1,4 @@
import { renderInTestApp } from '@backstage/frontend-test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { ExampleFetchComponent } from './ExampleFetchComponent';
describe('ExampleFetchComponent', () => {
@@ -0,0 +1 @@
export { {{ pluginVar }}, {{ extensionName }} } from './plugin';
@@ -1,20 +0,0 @@
# {{pluginId}}
Welcome to the {{pluginId}} plugin!
_This plugin was created through the Backstage CLI_
## Getting started
Your plugin has been added to the app in this repository, meaning you'll be able
to access it by running `yarn start` in the root directory, and then navigating
to [/{{pluginId}}](http://localhost:3000/{{pluginId}}).
This plugin is built with Backstage's [new frontend
system](https://backstage.io/docs/frontend-system/architecture/index), and you
can find more information about building plugins in the [plugin builder
documentation](https://backstage.io/docs/frontend-system/building-plugins/index).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
@@ -1,5 +0,0 @@
name: frontend-plugin
role: frontend-plugin
description: A new frontend plugin
values:
pluginVar: '{{ camelCase pluginId }}Plugin'
@@ -1 +0,0 @@
export { {{ pluginVar }} as default } from './plugin';