diff --git a/app/assets/javascripts/discourse/app/routes/application.js b/app/assets/javascripts/discourse/app/routes/application.js
index 0187ace3700..ee27aca1e3c 100644
--- a/app/assets/javascripts/discourse/app/routes/application.js
+++ b/app/assets/javascripts/discourse/app/routes/application.js
@@ -8,6 +8,7 @@ import { setting } from "discourse/lib/computed";
import cookie from "discourse/lib/cookie";
import logout from "discourse/lib/logout";
import mobile from "discourse/lib/mobile";
+import identifySource, { consolePrefix } from "discourse/lib/source-identifier";
import DiscourseURL from "discourse/lib/url";
import Category from "discourse/models/category";
import Composer from "discourse/models/composer";
@@ -39,6 +40,7 @@ const ApplicationRoute = DiscourseRoute.extend({
loadingSlider: service(),
router: service(),
siteSettings: service(),
+ clientErrorHandler: service(),
get includeExternalLoginMethods() {
return (
@@ -117,15 +119,24 @@ const ApplicationRoute = DiscourseRoute.extend({
const xhrOrErr = err.jqXHR ? err.jqXHR : err;
const exceptionController = this.controllerFor("exception");
- const c = window.console;
- if (c && c.error) {
- c.error(xhrOrErr);
- }
+ const themeOrPluginSource = identifySource(err);
+
+ // eslint-disable-next-line no-console
+ console.error(
+ ...[consolePrefix(err, themeOrPluginSource), xhrOrErr].filter(Boolean)
+ );
if (xhrOrErr && xhrOrErr.status === 404) {
return this.router.transitionTo("exception-unknown");
}
+ if (themeOrPluginSource) {
+ this.clientErrorHandler.displayErrorNotice(
+ "Error loading route",
+ themeOrPluginSource
+ );
+ }
+
exceptionController.setProperties({
lastTransition: transition,
thrown: xhrOrErr,
diff --git a/app/assets/javascripts/discourse/app/services/client-error-handler.js b/app/assets/javascripts/discourse/app/services/client-error-handler.js
index 07bda6e0a4c..082b7cfb12a 100644
--- a/app/assets/javascripts/discourse/app/services/client-error-handler.js
+++ b/app/assets/javascripts/discourse/app/services/client-error-handler.js
@@ -83,11 +83,15 @@ export default class ClientErrorHandlerService extends Service {
let html = `⚠️ ${escape(message)}`;
- if (source && source.type === "theme") {
+ if (source?.type === "theme") {
html += `
${I18n.t("themes.error_caused_by", {
name: escape(source.name),
path: source.path,
})}`;
+ } else if (source?.type === "plugin") {
+ html += `
${I18n.t("broken_plugin_alert", {
+ name: escape(source.name),
+ })}`;
}
html += `
${I18n.t(
diff --git a/app/assets/javascripts/discourse/tests/acceptance/client-error-handler-test.js b/app/assets/javascripts/discourse/tests/acceptance/client-error-handler-test.js
new file mode 100644
index 00000000000..4e53b4d7a10
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/acceptance/client-error-handler-test.js
@@ -0,0 +1,30 @@
+import { getOwner } from "@ember/application";
+import { visit } from "@ember/test-helpers";
+import { test } from "qunit";
+import Sinon from "sinon";
+import { acceptance } from "../helpers/qunit-helpers";
+
+acceptance("client-error-handler service", function (needs) {
+ needs.user({
+ admin: true,
+ });
+
+ test("displays route-loading errors caused by themes", async function (assert) {
+ const fakeError = new Error("Something bad happened");
+ fakeError.stack = "assets/plugins/some-fake-plugin-name.js";
+
+ const topicRoute = getOwner(this).lookup("route:topic");
+ Sinon.stub(topicRoute, "model").throws(fakeError);
+
+ const consoleStub = Sinon.stub(console, "error");
+ try {
+ await visit("/t/280");
+ } catch {}
+ consoleStub.restore();
+
+ assert.dom(".broken-theme-alert-banner").exists();
+ assert
+ .dom(".broken-theme-alert-banner")
+ .containsText("some-fake-plugin-name");
+ });
+});
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index eb48bb84a51..43d643687f6 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -221,6 +221,8 @@ en:
broken_decorator_alert: "Posts may not display correctly because one of the post content decorators on your site raised an error."
+ broken_plugin_alert: "Caused by plugin '%{name}'"
+
s3:
regions:
ap_northeast_1: "Asia Pacific (Tokyo)"