From 9409196e8e47cf86d13bec083f3db53bb15f2f87 Mon Sep 17 00:00:00 2001 From: weareoutman Date: Mon, 12 Aug 2024 15:12:30 +0800 Subject: [PATCH] fix(): refine error display --- cypress/e2e/sub-routes.spec.js | 2 +- .../middlewares/standaloneBootstrapJson.js | 1 + .../src/BootstrapError.shadow.css | 33 ++ .../brick-container/src/BootstrapError.ts | 19 + packages/brick-container/src/bootstrap.ts | 9 +- .../brick-container/src/styles/default.css | 4 + packages/loader/src/stableLoadBricks.spec.ts | 4 +- packages/loader/src/stableLoadBricks.ts | 17 +- packages/runtime/src/createRoot.spec.ts | 2 +- packages/runtime/src/createRoot.ts | 18 +- .../runtime/src/internal/ErrorNode.spec.ts | 339 ++++++++++++++++++ packages/runtime/src/internal/ErrorNode.ts | 184 ++++++++++ .../runtime/src/internal/Renderer.spec.ts | 16 +- packages/runtime/src/internal/Renderer.ts | 20 +- .../runtime/src/internal/RendererContext.ts | 9 +- packages/runtime/src/internal/Router.ts | 42 +-- packages/runtime/src/internal/Runtime.spec.ts | 40 ++- packages/runtime/src/internal/Runtime.ts | 17 +- packages/runtime/src/internal/i18n.ts | 24 ++ 19 files changed, 703 insertions(+), 97 deletions(-) create mode 100644 packages/brick-container/src/BootstrapError.shadow.css create mode 100644 packages/brick-container/src/BootstrapError.ts create mode 100644 packages/runtime/src/internal/ErrorNode.spec.ts create mode 100644 packages/runtime/src/internal/ErrorNode.ts diff --git a/cypress/e2e/sub-routes.spec.js b/cypress/e2e/sub-routes.spec.js index ecc1c81cd0..e507e009e9 100644 --- a/cypress/e2e/sub-routes.spec.js +++ b/cypress/e2e/sub-routes.spec.js @@ -56,7 +56,7 @@ for (const port of Cypress.env("ports")) { cy.contains("SyntaxError"); cy.expectMainContents([ ...fixedContents, - 'SyntaxError: Unexpected token (1:4), in "<% CTX. %>"', + 'Oops! Something went wrong: SyntaxError: Unexpected token (1:4), in "<% CTX. %>"', ]); cy.get("@console.error").should("be.called"); diff --git a/packages/brick-container/serve/middlewares/standaloneBootstrapJson.js b/packages/brick-container/serve/middlewares/standaloneBootstrapJson.js index c9c4f98760..fade604101 100644 --- a/packages/brick-container/serve/middlewares/standaloneBootstrapJson.js +++ b/packages/brick-container/serve/middlewares/standaloneBootstrapJson.js @@ -44,6 +44,7 @@ function getE2eSettings() { presetBricks: { notification: false, dialog: false, + error: false, }, }; } diff --git a/packages/brick-container/src/BootstrapError.shadow.css b/packages/brick-container/src/BootstrapError.shadow.css new file mode 100644 index 0000000000..fea9c06149 --- /dev/null +++ b/packages/brick-container/src/BootstrapError.shadow.css @@ -0,0 +1,33 @@ +:host { + display: flex; + flex-direction: column; + align-items: center; + padding: 48px 32px; +} + +:host([hidden]) { + display: none; +} + +.icon { + color: var(--color-error); + font-size: 72px; + margin-bottom: 24px; +} + +.icon svg { + display: block; +} + +.title { + color: var(--antd-heading-color); + font-size: 24px; + line-height: 1.8; +} + +.description { + color: var(--antd-text-color-secondary); + font-size: 14px; + line-height: 1.6; + text-align: center; +} diff --git a/packages/brick-container/src/BootstrapError.ts b/packages/brick-container/src/BootstrapError.ts new file mode 100644 index 0000000000..aef7590f3e --- /dev/null +++ b/packages/brick-container/src/BootstrapError.ts @@ -0,0 +1,19 @@ +// istanbul ignore file +import styleText from "./BootstrapError.shadow.css"; + +const icon = ``; + +export class BootstrapError extends HTMLElement { + connectedCallback() { + if (this.shadowRoot) { + return; + } + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = [ + ``, + `
${icon}
`, + '
启动错误
', + '
', + ].join(""); + } +} diff --git a/packages/brick-container/src/bootstrap.ts b/packages/brick-container/src/bootstrap.ts index 9266a5553d..fb783e2740 100644 --- a/packages/brick-container/src/bootstrap.ts +++ b/packages/brick-container/src/bootstrap.ts @@ -25,6 +25,7 @@ import { getSpanId } from "./utils.js"; import { listen } from "./preview/listen.js"; import { getMock } from "./mocks.js"; import { NS, locales } from "./i18n.js"; +import { BootstrapError } from "./BootstrapError.js"; analytics.initialize( `${getBasePath()}api/gateway/data_exchange.store.ClickHouseInsertData/api/v1/data_exchange/frontend_stat` @@ -140,9 +141,13 @@ async function main() { // `.bootstrap-error` makes loading-bar invisible. document.body.classList.add("bootstrap-error"); + customElements.define("easyops-bootstrap-error", BootstrapError); + const errorElement = document.createElement("easyops-bootstrap-error"); + errorElement.textContent = httpErrorToString(error); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - document.querySelector("#main-mount-point")!.textContent = - `bootstrap failed: ${httpErrorToString(error)}`; + document.querySelector("#main-mount-point")!.replaceChildren(errorElement); + return "failed"; } } diff --git a/packages/brick-container/src/styles/default.css b/packages/brick-container/src/styles/default.css index 624f438b8a..c08730247f 100644 --- a/packages/brick-container/src/styles/default.css +++ b/packages/brick-container/src/styles/default.css @@ -16,3 +16,7 @@ body.launchpad-open { .bootstrap-error #global-loading-bar { display: none; } + +#main-mount-point > illustrations\.error-message:only-child { + min-height: 100vh; +} diff --git a/packages/loader/src/stableLoadBricks.spec.ts b/packages/loader/src/stableLoadBricks.spec.ts index 211fc477e9..c422f8c139 100644 --- a/packages/loader/src/stableLoadBricks.spec.ts +++ b/packages/loader/src/stableLoadBricks.spec.ts @@ -325,7 +325,7 @@ describe("loadBricksImperatively", () => { ] ) ).rejects.toMatchInlineSnapshot( - `[Error: Load bricks of "unsure.not-existed" failed: oops]` + `[BrickLoadError: Load bricks of "unsure.not-existed" failed: oops]` ); expect(requestsCount).toBe(1); await promise; @@ -425,7 +425,7 @@ describe("loadBricksImperatively", () => { ] ) ).rejects.toMatchInlineSnapshot( - `[Error: Load bricks of "eo-will-fail" failed: oops]` + `[BrickLoadError: Load bricks of "eo-will-fail" failed: oops]` ); expect(requestsCount).toBe(1); await promise; diff --git a/packages/loader/src/stableLoadBricks.ts b/packages/loader/src/stableLoadBricks.ts index be5fa8a72f..98c7472bb2 100644 --- a/packages/loader/src/stableLoadBricks.ts +++ b/packages/loader/src/stableLoadBricks.ts @@ -81,6 +81,21 @@ export function loadEditorsImperatively( return dispatchRequestStatus(promise); } +export class BrickLoadError extends Error { + constructor(message: string) { + // Pass remaining arguments (including vendor specific ones) to parent constructor + super(message); + + this.name = "BrickLoadError"; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + // istanbul ignore else + if (Error.captureStackTrace) { + Error.captureStackTrace(this, BrickLoadError); + } + } +} + interface V2AdapterBrick { resolve( adapterPkgFilePath: string, @@ -186,7 +201,7 @@ async function loadBrickModule( } catch (error) { // eslint-disable-next-line no-console console.error(error); - throw new Error( + throw new BrickLoadError( `Load ${type} of "${item.fullName}" failed: ${(error as Error)?.message}` ); } diff --git a/packages/runtime/src/createRoot.spec.ts b/packages/runtime/src/createRoot.spec.ts index 72eefebf90..79237038ec 100644 --- a/packages/runtime/src/createRoot.spec.ts +++ b/packages/runtime/src/createRoot.spec.ts @@ -240,7 +240,7 @@ describe("preview", () => { ]); expect(container.innerHTML).toBe( - '
ReferenceError: QUERY is not defined, in "<% QUERY.q %>"
' + '
UNKNOWN_ERROR: ReferenceError: QUERY is not defined, in "<% QUERY.q %>"
' ); expect(portal.innerHTML).toBe(""); expect(applyTheme).not.toBeCalled(); diff --git a/packages/runtime/src/createRoot.ts b/packages/runtime/src/createRoot.ts index 82c87d9690..f14689f78c 100644 --- a/packages/runtime/src/createRoot.ts +++ b/packages/runtime/src/createRoot.ts @@ -20,13 +20,13 @@ import { RendererContext } from "./internal/RendererContext.js"; import { DataStore } from "./internal/data/DataStore.js"; import type { RenderRoot, RuntimeContext } from "./internal/interfaces.js"; import { mountTree, unmountTree } from "./internal/mount.js"; -import { httpErrorToString } from "./handleHttpError.js"; import { applyMode, applyTheme, setMode, setTheme } from "./themeAndMode.js"; import { RenderTag } from "./internal/enums.js"; import { registerStoryboardFunctions } from "./internal/compute/StoryboardFunctions.js"; import { registerAppI18n } from "./internal/registerAppI18n.js"; import { registerCustomTemplates } from "./internal/registerCustomTemplates.js"; import { setUIVersion } from "./setUIVersion.js"; +import { ErrorNode } from "./internal/ErrorNode.js"; export interface CreateRootOptions { portal?: HTMLElement; @@ -182,21 +182,7 @@ export function unstable_createRoot( } catch (error) { failed = true; output = { - node: { - tag: RenderTag.BRICK, - type: "div", - properties: { - textContent: httpErrorToString(error), - dataset: { - errorBoundary: "", - }, - style: { - color: "var(--color-error)", - }, - }, - return: renderRoot, - runtimeContext: null!, - }, + node: await ErrorNode(error, renderRoot, scope === "page"), blockingList: [], }; } diff --git a/packages/runtime/src/internal/ErrorNode.spec.ts b/packages/runtime/src/internal/ErrorNode.spec.ts new file mode 100644 index 0000000000..b56ac33bce --- /dev/null +++ b/packages/runtime/src/internal/ErrorNode.spec.ts @@ -0,0 +1,339 @@ +import { jest, describe, test, expect } from "@jest/globals"; +import { BrickLoadError, loadBricksImperatively } from "@next-core/loader"; +import { initializeI18n } from "@next-core/i18n"; +import { ErrorNode, PageNotFoundError } from "./ErrorNode.js"; +import { RenderTag } from "./enums.js"; +import type { RenderReturnNode } from "./interfaces.js"; +import { HttpResponseError } from "@next-core/http"; +import { _internalApiGetPresetBricks } from "./Runtime.js"; + +initializeI18n(); + +jest.mock("@next-core/loader", () => ({ + BrickLoadError: class extends Error { + constructor(message: string) { + super(message); + this.name = "BrickLoadError"; + } + }, + loadBricksImperatively: jest.fn<() => Promise>().mockResolvedValue(), +})); + +jest.mock("./Runtime.js", () => ({ + _internalApiGetPresetBricks: jest.fn().mockImplementation(() => ({})), + getBrickPackages() { + return []; + }, +})); + +const mockedLoadBricks = loadBricksImperatively as jest.MockedFunction< + () => Promise +>; +const mockedGetPresetBricks = + _internalApiGetPresetBricks as jest.MockedFunction< + typeof _internalApiGetPresetBricks + >; + +describe("ErrorNode", () => { + test("default error", async () => { + expect( + await ErrorNode(new Error("oops"), { + tag: RenderTag.ROOT, + } as RenderReturnNode) + ).toEqual({ + properties: { + dataset: { + errorBoundary: "", + }, + style: { + color: "var(--color-error)", + }, + textContent: "UNKNOWN_ERROR: Error: oops", + }, + return: { + tag: 1, + }, + runtimeContext: null, + tag: 2, + type: "div", + }); + }); + + test("no permission", async () => { + expect( + await ErrorNode( + new HttpResponseError({ + status: 403, + statusText: "Forbidden", + } as Response), + { + tag: RenderTag.ROOT, + } as RenderReturnNode + ) + ).toEqual({ + properties: { + dataset: { + errorBoundary: "", + }, + style: { + color: "var(--color-error)", + }, + textContent: "NO_PERMISSION: HttpResponseError: Forbidden", + }, + return: { + tag: 1, + }, + runtimeContext: null, + tag: 2, + type: "div", + }); + }); + + test("network error", async () => { + expect( + await ErrorNode(new BrickLoadError("oops"), { + tag: RenderTag.ROOT, + } as RenderReturnNode) + ).toEqual({ + properties: { + dataset: { + errorBoundary: "", + }, + style: { + color: "var(--color-error)", + }, + textContent: "NETWORK_ERROR: BrickLoadError: oops", + }, + return: { + tag: 1, + }, + runtimeContext: null, + tag: 2, + type: "div", + }); + }); + + test("page level without go back link", async () => { + expect( + await ErrorNode( + new Error("oops"), + { + tag: RenderTag.ROOT, + } as RenderReturnNode, + true + ) + ).toEqual({ + properties: { + dataset: { + errorBoundary: "", + }, + variant: "unknown-error", + errorTitle: "UNKNOWN_ERROR", + description: "Error: oops", + }, + return: { + tag: 1, + }, + runtimeContext: null, + tag: 2, + type: "illustrations.error-message", + child: expect.objectContaining({ + type: "eo-link", + properties: { + textContent: "GO_BACK_TO_PREVIOUS_PAGE", + }, + events: { + click: { + action: "history.goBack", + }, + }, + }), + }); + }); + + test("page level with go back link", async () => { + expect( + await ErrorNode( + new HttpResponseError( + { + status: 400, + statusText: "Bad Request", + } as Response, + { + code: "200000", + } + ), + { + tag: RenderTag.ROOT, + } as RenderReturnNode, + true + ) + ).toEqual({ + properties: { + dataset: { + errorBoundary: "", + }, + variant: "license-expired", + errorTitle: "LICENSE_EXPIRED", + description: "HttpResponseError: Bad Request", + }, + return: { + tag: 1, + }, + runtimeContext: null, + tag: 2, + type: "illustrations.error-message", + }); + }); + + test("page level and load bricks timeout", async () => { + mockedLoadBricks.mockImplementationOnce(() => { + return new Promise((resolve) => setTimeout(resolve, 4e3)); + }); + const consoleError = jest.spyOn(console, "error").mockReturnValue(); + + jest.useFakeTimers(); + + const promise = ErrorNode( + new HttpResponseError( + { + status: 400, + statusText: "Bad Request", + } as Response, + { + code: "200000", + } + ), + { + tag: RenderTag.ROOT, + } as RenderReturnNode, + true + ); + + jest.advanceTimersByTime(3e3); + + expect(await promise).toEqual({ + properties: { + dataset: { + errorBoundary: "", + }, + style: { + color: "var(--color-error)", + }, + textContent: "LICENSE_EXPIRED: HttpResponseError: Bad Request", + }, + return: { + tag: 1, + }, + runtimeContext: null, + tag: 2, + type: "div", + }); + + expect(consoleError).toBeCalledTimes(1); + + consoleError.mockRestore(); + }); + + test("page not found", async () => { + expect( + await ErrorNode( + new PageNotFoundError("page not found"), + { + tag: RenderTag.ROOT, + } as RenderReturnNode, + true + ) + ).toEqual({ + properties: { + dataset: { + errorBoundary: "", + }, + variant: "not-found", + errorTitle: "PAGE_NOT_FOUND", + description: undefined, + }, + return: { + tag: 1, + }, + runtimeContext: null, + tag: 2, + type: "illustrations.error-message", + child: expect.objectContaining({ + type: "eo-link", + properties: { + textContent: "GO_BACK_HOME", + url: "/", + }, + events: undefined, + }), + }); + }); + + test("app not found", async () => { + expect( + await ErrorNode( + new PageNotFoundError("app not found"), + { + tag: RenderTag.ROOT, + } as RenderReturnNode, + true + ) + ).toEqual({ + properties: { + dataset: { + errorBoundary: "", + }, + variant: "no-permission", + errorTitle: "APP_NOT_FOUND", + description: undefined, + }, + return: { + tag: 1, + }, + runtimeContext: null, + tag: 2, + type: "illustrations.error-message", + child: expect.objectContaining({ + type: "eo-link", + properties: { + textContent: "GO_BACK_HOME", + url: "/", + }, + events: undefined, + }), + }); + }); + + test("app not found but preset error brick is false", async () => { + mockedGetPresetBricks.mockReturnValueOnce({ + error: false, + }); + + expect( + await ErrorNode( + new PageNotFoundError("app not found"), + { + tag: RenderTag.ROOT, + } as RenderReturnNode, + true + ) + ).toEqual({ + properties: { + dataset: { + errorBoundary: "", + }, + style: { + color: "var(--color-error)", + }, + textContent: "APP_NOT_FOUND", + }, + return: { + tag: 1, + }, + runtimeContext: null, + tag: 2, + type: "div", + }); + }); +}); diff --git a/packages/runtime/src/internal/ErrorNode.ts b/packages/runtime/src/internal/ErrorNode.ts new file mode 100644 index 0000000000..4b1b058893 --- /dev/null +++ b/packages/runtime/src/internal/ErrorNode.ts @@ -0,0 +1,184 @@ +import { loadBricksImperatively, BrickLoadError } from "@next-core/loader"; +import { HttpResponseError } from "@next-core/http"; +import { i18n } from "@next-core/i18n"; +import { httpErrorToString } from "../handleHttpError.js"; +import { RenderTag } from "./enums.js"; +import type { RenderChildNode, RenderReturnNode } from "./interfaces.js"; +import { _internalApiGetPresetBricks, getBrickPackages } from "./Runtime.js"; +import { K, NS } from "./i18n.js"; + +type ErrorMessageVariant = + | "internet-disconnected" + | "no-permission" + | "license-expired" + | "not-found" + | "unknown-error"; + +interface ErrorMessageConfig { + title: string; + variant: ErrorMessageVariant; + showLink?: "home" | "previous"; + showDescription?: boolean; +} + +export class PageNotFoundError extends Error { + constructor(message: "page not found" | "app not found") { + // Pass remaining arguments (including vendor specific ones) to parent constructor + super(message); + + this.name = "PageNotFoundError"; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + // istanbul ignore else + if (Error.captureStackTrace) { + Error.captureStackTrace(this, BrickLoadError); + } + } +} + +/** + * Will always resolve + */ +export async function ErrorNode( + error: unknown, + returnNode: RenderReturnNode, + pageLevel?: boolean +): Promise { + const { title, variant, showLink, showDescription } = + getRefinedErrorConf(error); + + if (pageLevel) { + const presetBricks = _internalApiGetPresetBricks(); + const errorBrick = presetBricks.error ?? "illustrations.error-message"; + if (errorBrick !== false) { + const linkBrick = "eo-link"; + const bricks = showLink ? [errorBrick, linkBrick] : [errorBrick]; + try { + await Promise.race([ + loadBricksImperatively(bricks, getBrickPackages()), + // Timeout after 3 seconds + new Promise((_resolve, reject) => + setTimeout(() => { + reject(new Error("timeout")); + }, 3e3) + ), + ]); + const node: RenderChildNode = { + tag: RenderTag.BRICK, + type: errorBrick, + properties: { + errorTitle: title, + description: showDescription ? httpErrorToString(error) : undefined, + variant, + dataset: { + errorBoundary: "", + }, + }, + runtimeContext: null!, + return: returnNode, + }; + + if (showLink) { + node.child = { + tag: RenderTag.BRICK, + type: linkBrick, + properties: { + textContent: + showLink === "home" + ? i18n.t(`${NS}:${K.GO_BACK_HOME}`) + : i18n.t(`${NS}:${K.GO_BACK_TO_PREVIOUS_PAGE}`), + url: showLink === "home" ? "/" : undefined, + }, + events: + showLink === "home" + ? undefined + : { + click: { + action: "history.goBack", + }, + }, + runtimeContext: null!, + return: node, + }; + } + + return node; + } catch (e) { + // eslint-disable-next-line no-console + console.error("Failed to load brick:", bricks.join(", "), e); + } + } + } + + return { + tag: RenderTag.BRICK, + type: "div", + properties: { + textContent: showDescription + ? `${title}: ${httpErrorToString(error)}` + : title, + dataset: { + errorBoundary: "", + }, + style: { + color: "var(--color-error)", + }, + }, + runtimeContext: null!, + return: returnNode, + }; +} + +function getRefinedErrorConf(error: unknown): ErrorMessageConfig { + if (error instanceof PageNotFoundError) { + return error.message === "app not found" + ? { + showLink: "home", + title: i18n.t(`${NS}:${K.APP_NOT_FOUND}`), + variant: "no-permission", + } + : { + showLink: "home", + variant: "not-found", + title: i18n.t(`${NS}:${K.PAGE_NOT_FOUND}`), + }; + } + + if ( + error instanceof BrickLoadError || + (error instanceof Error && error.name === "ChunkLoadError") + ) { + return { + showDescription: true, + title: i18n.t(`${NS}:${K.NETWORK_ERROR}`), + variant: "internet-disconnected", + }; + } + + if (error instanceof HttpResponseError && error.response?.status === 403) { + return { + showLink: "previous", + showDescription: true, + title: i18n.t(`${NS}:${K.NO_PERMISSION}`), + variant: "no-permission", + }; + } + + if ( + error instanceof HttpResponseError && + error.responseJson?.code === "200000" + ) { + return { + showDescription: true, + title: i18n.t(`${NS}:${K.LICENSE_EXPIRED}`), + variant: "license-expired", + }; + } + + return { + showLink: "previous", + showDescription: true, + title: i18n.t(`${NS}:${K.UNKNOWN_ERROR}`), + variant: "unknown-error", + }; +} diff --git a/packages/runtime/src/internal/Renderer.spec.ts b/packages/runtime/src/internal/Renderer.spec.ts index fd9208fb8f..3ecc54a605 100644 --- a/packages/runtime/src/internal/Renderer.spec.ts +++ b/packages/runtime/src/internal/Renderer.spec.ts @@ -1,11 +1,6 @@ import { jest, describe, test, expect } from "@jest/globals"; import type { RouteConf, RouteConfOfBricks } from "@next-core/types"; import { createProviderClass } from "@next-core/utils/general"; -import { RenderBrick, RenderRoot, RuntimeContext } from "./interfaces.js"; -import { RenderTag } from "./enums.js"; -import { renderBrick, renderBricks, renderRoutes } from "./Renderer.js"; -import { RendererContext } from "./RendererContext.js"; -import { DataStore } from "./data/DataStore.js"; import { enqueueStableLoadBricks, loadBricksImperatively, @@ -13,6 +8,12 @@ import { loadScript, loadStyle, } from "@next-core/loader"; +import { initializeI18n } from "@next-core/i18n"; +import { RenderBrick, RenderRoot, RuntimeContext } from "./interfaces.js"; +import { RenderTag } from "./enums.js"; +import { renderBrick, renderBricks, renderRoutes } from "./Renderer.js"; +import { RendererContext } from "./RendererContext.js"; +import { DataStore } from "./data/DataStore.js"; import { mountTree, unmountTree } from "./mount.js"; import { getHistory } from "../history.js"; import { mediaEventTarget } from "./mediaQuery.js"; @@ -26,6 +27,8 @@ import * as compute from "./compute/computeRealValue.js"; import { customProcessors } from "../CustomProcessors.js"; import * as __secret_internals from "./secret_internals.js"; +initializeI18n(); + jest.mock("@next-core/loader"); jest.mock("../history.js"); jest.mock("./Runtime.js", () => ({ @@ -951,7 +954,8 @@ describe("renderBrick", () => { tag: RenderTag.BRICK, type: "div", properties: { - textContent: 'ReferenceError: ABC is not defined, in "<% ABC %>"', + textContent: + 'UNKNOWN_ERROR: ReferenceError: ABC is not defined, in "<% ABC %>"', dataset: { errorBoundary: "", }, diff --git a/packages/runtime/src/internal/Renderer.ts b/packages/runtime/src/internal/Renderer.ts index 892868fa9a..fde08b34e4 100644 --- a/packages/runtime/src/internal/Renderer.ts +++ b/packages/runtime/src/internal/Renderer.ts @@ -75,8 +75,8 @@ import type { DataStore, DataStoreType } from "./data/DataStore.js"; import { listenerFactory } from "./bindListeners.js"; import type { MatchResult } from "./matchPath.js"; import { setupRootRuntimeContext } from "./setupRootRuntimeContext.js"; -import { httpErrorToString } from "../handleHttpError.js"; import { setMatchedRoute } from "./routeMatchedMap.js"; +import { ErrorNode } from "./ErrorNode.js"; export interface RenderOutput { node?: RenderChildNode; @@ -293,21 +293,7 @@ export async function renderBrick( // eslint-disable-next-line no-console console.error("Error caught by error boundary:", error); return { - node: { - tag: RenderTag.BRICK, - type: "div", - properties: { - textContent: httpErrorToString(error), - dataset: { - errorBoundary: "", - }, - style: { - color: "var(--color-error)", - }, - }, - runtimeContext: null!, - return: returnNode, - }, + node: await ErrorNode(error, returnNode), blockingList: [], }; } else { @@ -865,7 +851,7 @@ async function legacyRenderBrick( // eslint-disable-next-line no-console console.error("Incremental sub-router failed:", error); - const result = rendererContext.reCatch(error, brick); + const result = await rendererContext.reCatch(error, brick); if (!result) { return true; } diff --git a/packages/runtime/src/internal/RendererContext.ts b/packages/runtime/src/internal/RendererContext.ts index 57aa128a7a..78960e5f1d 100644 --- a/packages/runtime/src/internal/RendererContext.ts +++ b/packages/runtime/src/internal/RendererContext.ts @@ -71,15 +71,17 @@ export interface RendererContextOptions { export interface RouteHelper { bailout: (output: RenderOutput) => true | undefined; mergeMenus: (menuRequests: Promise[]) => Promise; + /** Will always resolve */ catch: ( error: unknown, returnNode: RenderReturnNode - ) => + ) => Promise< | undefined | { failed: true; output: RenderOutput; - }; + } + >; } export class RendererContext { @@ -221,6 +223,9 @@ export class RendererContext { return this.#routeHelper!.bailout(output); } + /** + * Will always resolve + */ reCatch(error: unknown, returnNode: RenderReturnNode) { return this.#routeHelper!.catch(error, returnNode); } diff --git a/packages/runtime/src/internal/Router.ts b/packages/runtime/src/internal/Router.ts index 8e2dab1324..4ae970d880 100644 --- a/packages/runtime/src/internal/Router.ts +++ b/packages/runtime/src/internal/Router.ts @@ -39,16 +39,11 @@ import { import { getPageInfo } from "../getPageInfo.js"; import type { MenuRequestNode, - RenderBrick, RenderRoot, RuntimeContext, } from "./interfaces.js"; import { resetAllComputedMarks } from "./compute/markAsComputed.js"; -import { - handleHttpError, - httpErrorToString, - isUnauthenticatedError, -} from "../handleHttpError.js"; +import { handleHttpError, isUnauthenticatedError } from "../handleHttpError.js"; import { abortPendingRequest, initAbortController } from "./abortController.js"; import { setLoginStateCookie } from "../setLoginStateCookie.js"; import { registerCustomTemplates } from "./registerCustomTemplates.js"; @@ -60,6 +55,7 @@ import { setUIVersion } from "../setUIVersion.js"; import { setAppVariable } from "../setAppVariable.js"; import { setWatermark } from "../setWatermark.js"; import { clearMatchedRoutes } from "./routeMatchedMap.js"; +import { ErrorNode, PageNotFoundError } from "./ErrorNode.js"; type RenderTask = InitialRenderTask | SubsequentRenderTask; @@ -406,7 +402,7 @@ export class Router { new CustomEvent("navConfig.change", { detail: this.#navConfig }) ); }, - catch: (error, returnNode) => { + catch: async (error, returnNode) => { if (isUnauthenticatedError(error) && !window.NO_AUTH_GUARD) { redirectToLogin(); return; @@ -423,21 +419,7 @@ export class Router { return { failed: true, output: { - node: { - tag: RenderTag.BRICK, - type: "div", - properties: { - textContent: httpErrorToString(error), - dataset: { - errorBoundary: "", - }, - style: { - color: "var(--color-error)", - }, - }, - runtimeContext: null!, - return: returnNode, - }, + node: await ErrorNode(error, returnNode, true), blockingList: [], }, }; @@ -506,7 +488,7 @@ export class Router { // eslint-disable-next-line no-console console.error("Router failed:", error); - const result = routeHelper.catch(error, renderRoot); + const result = await routeHelper.catch(error, renderRoot); if (!result) { return; } @@ -571,15 +553,11 @@ export class Router { applyTheme(); applyMode(); - const node: RenderBrick = { - tag: RenderTag.BRICK, - type: "div", - properties: { - textContent: "Page not found", - }, - runtimeContext: null!, - return: renderRoot, - }; + const node = await ErrorNode( + new PageNotFoundError(currentApp ? "page not found" : "app not found"), + renderRoot, + true + ); renderRoot.child = node; mountTree(renderRoot); diff --git a/packages/runtime/src/internal/Runtime.spec.ts b/packages/runtime/src/internal/Runtime.spec.ts index 7a6ee3156a..6dfd702b39 100644 --- a/packages/runtime/src/internal/Runtime.spec.ts +++ b/packages/runtime/src/internal/Runtime.spec.ts @@ -7,6 +7,7 @@ import { HttpResponseError as _HttpResponseError, HttpAbortError as _HttpAbortError, } from "@next-core/http"; +import { initializeI18n } from "@next-core/i18n"; import { createRuntime as _createRuntime, getRuntime as _getRuntime, @@ -16,6 +17,8 @@ import { loadNotificationService } from "../Notification.js"; import { loadDialogService } from "../Dialog.js"; import { getHistory as _getHistory } from "../history.js"; +initializeI18n(); + jest.mock("@next-core/loader"); jest.mock("../Dialog.js"); jest.mock("../Notification.js"); @@ -589,6 +592,17 @@ customElements.define( createProviderClass(myAbortProvider) ); +customElements.define( + "illustrations.error-message", + class IllustrationsErrorMessage extends HTMLElement { + errorTitle?: string; + variant?: string; + description?: string; + } +); + +customElements.define("eo-link", class EoLink extends HTMLElement {}); + describe("Runtime", () => { let createRuntime: typeof _createRuntime; let getRuntime: typeof _getRuntime; @@ -876,11 +890,13 @@ describe("Runtime", () => { Hello
-
- SyntaxError: Unexpected token (1:4), in "<% Sub 3 %>" -
+ + Go back to previous page + +
,
{
-
- TypeError: bricks is not iterable -
+ + Go back to previous page + +
,
{
-
- Page not found -
+ + + Go back to home page + +
,
) { return loadBricksImperatively(bricks, getBrickPackages()); } - - #getPresetBricks() { - return (bootstrapData?.settings?.presetBricks ?? {}) as { - notification?: string | false; - dialog?: string | false; - }; - } } function normalizeBootstrapData(data: BootstrapData) { @@ -352,6 +345,14 @@ export function getBrickPackages(): BrickPackage[] { ); } +export function _internalApiGetPresetBricks() { + return (bootstrapData?.settings?.presetBricks ?? {}) as { + notification?: string | false; + dialog?: string | false; + error?: string | false; + }; +} + export function _internalApiGetRenderId(): string | undefined { return router?.getRenderId(); } diff --git a/packages/runtime/src/internal/i18n.ts b/packages/runtime/src/internal/i18n.ts index fad59f9082..8eea3a22c9 100644 --- a/packages/runtime/src/internal/i18n.ts +++ b/packages/runtime/src/internal/i18n.ts @@ -3,6 +3,13 @@ export enum K { SOMETHING_WENT_WRONG = "SOMETHING_WENT_WRONG", LOGIN_TIMEOUT_MESSAGE = "LOGIN_TIMEOUT_MESSAGE", NETWORK_ERROR = "NETWORK_ERROR", + LICENSE_EXPIRED = "LICENSE_EXPIRED", + NO_PERMISSION = "NO_PERMISSION", + PAGE_NOT_FOUND = "PAGE_NOT_FOUND", + APP_NOT_FOUND = "APP_NOT_FOUND", + UNKNOWN_ERROR = "UNKNOWN_ERROR", + GO_BACK_TO_PREVIOUS_PAGE = "GO_BACK_TO_PREVIOUS_PAGE", + GO_BACK_HOME = "GO_BACK_HOME", } const en: Locale = { @@ -11,6 +18,16 @@ const en: Locale = { [K.LOGIN_TIMEOUT_MESSAGE]: "You haven't logged in or your login session has expired. Login right now?", [K.NETWORK_ERROR]: "Network error, please check your network.", + [K.LICENSE_EXPIRED]: + "The license authorization has expired, please contact the platform administrator", + [K.NO_PERMISSION]: + "Unauthorized access, unable to retrieve the required resources for this page", + [K.PAGE_NOT_FOUND]: "Page not found, please check the URL", + [K.APP_NOT_FOUND]: + "App not found, maybe the URL is wrong or you don't have permission to access", + [K.UNKNOWN_ERROR]: "Oops! Something went wrong", + [K.GO_BACK_TO_PREVIOUS_PAGE]: "Go back to previous page", + [K.GO_BACK_HOME]: "Go back to home page", }; const zh: Locale = { @@ -18,6 +35,13 @@ const zh: Locale = { [K.SOMETHING_WENT_WRONG]: "出现了一些问题!", [K.LOGIN_TIMEOUT_MESSAGE]: "您还未登录或登录信息已过期,现在重新登录?", [K.NETWORK_ERROR]: "网络错误,请检查您的网络连接。", + [K.LICENSE_EXPIRED]: "License 授权失效,请联系平台管理员", + [K.NO_PERMISSION]: "没有权限,无法获取页面所需要的资源", + [K.PAGE_NOT_FOUND]: "请求的页面未找到,请确认 URL 是否正确", + [K.APP_NOT_FOUND]: "请求的微应用无法找到, 可能是 URL 错误或者无权限访问", + [K.UNKNOWN_ERROR]: "糟糕!页面出现了一些问题", + [K.GO_BACK_TO_PREVIOUS_PAGE]: "回到上一页", + [K.GO_BACK_HOME]: "回到首页", }; export const NS = "core/runtime";