Skip to content

Commit

Permalink
fix(): try to reload when specified error occurs
Browse files Browse the repository at this point in the history
INFRA-0
  • Loading branch information
weareoutman committed Jan 2, 2025
1 parent a66abb0 commit c91b3d2
Show file tree
Hide file tree
Showing 12 changed files with 236 additions and 10 deletions.
9 changes: 9 additions & 0 deletions etc/runtime.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@ export function instantiateModalStack(initialIndex?: number): ModalStack;
// @public @deprecated (undocumented)
export function isLoggedIn(): boolean | undefined;

// @public (undocumented)
export function isNetworkError(error: unknown): boolean;

// @public (undocumented)
export function isUnauthenticatedError(error: unknown): boolean;

Expand Down Expand Up @@ -395,6 +398,9 @@ interface RenderUseBrickResult {
tagName: string | null;
}

// @public (undocumented)
export function resetReloadForError(): void;

// @public (undocumented)
interface RuntimeContext extends LegacyCompatibleRuntimeContext {
// (undocumented)
Expand Down Expand Up @@ -519,6 +525,9 @@ function setRealTimeDataInspectRoot(root: RealTimeDataInspectRoot): void;
// @public (undocumented)
export function setUIVersion(version: string | undefined | null): void;

// @public (undocumented)
export function shouldReloadForError(error: unknown): boolean;

// Warning: (ae-forgotten-export) The symbol "StoryboardFunctionRegistry" needs to be exported by the entry point index.d.ts
// Warning: (ae-internal-missing-underscore) The name "StoryboardFunctionRegistryFactory" should be prefixed with an underscore because the declaration is marked as @internal
//
Expand Down
3 changes: 2 additions & 1 deletion packages/brick-container/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"compression": "^1.7.4",
"express": "^4.21.0",
"glob": "^8.1.0",
"http-proxy-middleware": "^3.0.2",
"http-proxy-middleware": "^3.0.3",
"js-yaml": "^3.14.1",
"lodash": "^4.17.21",
"meow": "^11.0.0",
Expand All @@ -62,6 +62,7 @@
"@next-core/test-next": "^1.1.7",
"@next-core/theme": "^1.5.4",
"@next-core/types": "^1.14.0",
"@next-core/utils": "^1.7.28",
"broadcast-channel": "^7.0.0",
"copy-webpack-plugin": "^12.0.2",
"core-js": "^3.38.1",
Expand Down
13 changes: 12 additions & 1 deletion packages/brick-container/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import {
createRuntime,
getBasePath,
httpErrorToString,
shouldReloadForError,
resetReloadForError,
__secret_internals,
isNetworkError,
} from "@next-core/runtime";
import { HttpRequestConfig, http } from "@next-core/http";
import { i18n, initializeI18n } from "@next-core/i18n";
Expand Down Expand Up @@ -135,18 +138,26 @@ async function main() {
loadBootstrapData(),
]);
await runtime.bootstrap(bootstrapData);
resetReloadForError();
return "ok";
} catch (error) {
// eslint-disable-next-line no-console
console.error("bootstrap failed:", error);

if (shouldReloadForError(error)) {
location.reload();
return "failed";
}

// `.bootstrap-error` makes loading-bar invisible.
document.body.classList.add("bootstrap-error");

const errorElement = document.createElement(
"easyops-default-error"
) as DefaultError;
errorElement.errorTitle = i18n.t(`${NS}:${K.BOOTSTRAP_ERROR}`);
errorElement.errorTitle = isNetworkError(error)
? i18n.t(`${NS}:${K.NETWORK_ERROR}`)
: i18n.t(`${NS}:${K.BOOTSTRAP_ERROR}`);
errorElement.textContent = httpErrorToString(error);
const linkElement = document.createElement("a");
linkElement.slot = "link";
Expand Down
3 changes: 3 additions & 0 deletions packages/brick-container/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export enum K {
LOGIN_CHANGED = "LOGIN_CHANGED",
LOGOUT_APPLIED = "LOGOUT_APPLIED",
BOOTSTRAP_ERROR = "BOOTSTRAP_ERROR",
NETWORK_ERROR = "NETWORK_ERROR",
RELOAD = "RELOAD",
}

Expand All @@ -11,13 +12,15 @@ const en: Locale = {
[K.LOGOUT_APPLIED]:
"Your account has been logged out, click OK to refresh the page.",
[K.BOOTSTRAP_ERROR]: "Bootstrap Error",
[K.NETWORK_ERROR]: "Network Error",
[K.RELOAD]: "Reload",
};

const zh: Locale = {
[K.LOGIN_CHANGED]: "您已经登录另一个账号,点击确定刷新页面。",
[K.LOGOUT_APPLIED]: "您的账号已经登出,点击确定刷新页面。",
[K.BOOTSTRAP_ERROR]: "启动错误",
[K.NETWORK_ERROR]: "网络错误",
[K.RELOAD]: "刷新",
};

Expand Down
2 changes: 2 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,5 @@ export { Dialog, type DialogOptions } from "./Dialog.js";
export * from "./getV2RuntimeFromDll.js";
export { setUIVersion } from "./setUIVersion.js";
export * from "./ModalStack.js";
export * from "./isNetworkError.js";
export * from "./shouldReloadForError.js";
9 changes: 9 additions & 0 deletions packages/runtime/src/internal/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ import { setAppVariable } from "../setAppVariable.js";
import { setWatermark } from "../setWatermark.js";
import { clearMatchedRoutes } from "./routeMatchedMap.js";
import { ErrorNode, PageNotFoundError } from "./ErrorNode.js";
import {
resetReloadForError,
shouldReloadForError,
} from "../shouldReloadForError.js";

type RenderTask = InitialRenderTask | SubsequentRenderTask;

Expand Down Expand Up @@ -426,6 +430,10 @@ export class Router {
if (isCurrentBootstrap) {
throw error;
}
if (!isReCatch && shouldReloadForError(error)) {
window.location.reload();
return;
}
return {
failed: true,
output: {
Expand Down Expand Up @@ -506,6 +514,7 @@ export class Router {
}
({ failed, output } = result);
}
resetReloadForError();
renderRoot.child = output.node;
this.#bootstrapFailed = false;

Expand Down
70 changes: 66 additions & 4 deletions packages/runtime/src/internal/Runtime.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, test, expect, jest } from "@jest/globals";
import { fireEvent } from "@testing-library/dom";
import { createProviderClass } from "@next-core/utils/general";
import { loadBricksImperatively } from "@next-core/loader";
import { BrickLoadError, loadBricksImperatively } from "@next-core/loader";
import type { BootstrapData } from "@next-core/types";
import {
HttpResponseError as _HttpResponseError,
Expand All @@ -16,12 +16,14 @@ import {
import { loadNotificationService } from "../Notification.js";
import { loadDialogService } from "../Dialog.js";
import { getHistory as _getHistory } from "../history.js";
import { shouldReloadForError } from "../shouldReloadForError.js";

initializeI18n();

jest.mock("@next-core/loader");
jest.mock("../Dialog.js");
jest.mock("../Notification.js");
jest.mock("../shouldReloadForError.js");

const consoleError = jest.spyOn(console, "error");
window.scrollTo = jest.fn();
Expand Down Expand Up @@ -580,6 +582,24 @@ const getBootstrapData = (options?: {
exact: true,
bricks: null!,
},
{
path: "${APP.homepage}/brick-load-error",
exact: true,
context: [
{
name: "test",
resolve: {
useProvider: "my-time-out-provider",
args: [0, undefined, "BrickLoadError"],
},
},
],
bricks: [
{
brick: "div",
},
],
},
{
path: "${APP.homepage}/unauthenticated",
exact: true,
Expand Down Expand Up @@ -645,9 +665,15 @@ const getBootstrapData = (options?: {
});

const myTimeoutProvider = jest.fn(
(timeout: number, result?: unknown, fail?: boolean) =>
(timeout: number, result?: unknown, fail?: boolean | string) =>
new Promise((resolve, reject) => {
setTimeout(() => (fail ? reject : resolve)(result), timeout);
setTimeout(
() =>
(fail ? reject : resolve)(
fail === "BrickLoadError" ? new BrickLoadError("oops") : result
),
timeout
);
})
);
customElements.define(
Expand Down Expand Up @@ -1712,6 +1738,41 @@ describe("Runtime", () => {
`);
});

test("brick load error after bootstrap", async () => {
const originalLocation = window.location;
delete (window as any).location;
window.location = {
...originalLocation,
reload: jest.fn(),
} as any;
consoleError.mockReturnValueOnce();
(shouldReloadForError as jest.Mock).mockReturnValueOnce(true);
const finishPageView = jest.fn();
createRuntime({
hooks: {
pageView: {
create: jest.fn(() => finishPageView),
},
},
}).initialize(getBootstrapData());
getHistory().push("/app-b");
await getRuntime().bootstrap();
getHistory().push("/app-b/brick-load-error");
await (global as any).flushPromises();
expect(document.body.children).toMatchInlineSnapshot(`
HTMLCollection [
<div
id="main-mount-point"
/>,
<div
id="portal-mount-point"
/>,
]
`);
expect(window.location.reload).toBeCalled();
window.location = originalLocation;
});

test("render locale title", async () => {
consoleError.mockReturnValueOnce();
const finishPageView = jest.fn();
Expand Down Expand Up @@ -1777,7 +1838,7 @@ describe("Runtime", () => {
`);
});

test("redirect to other login page if not logged in", async () => {
test("redirect to other login page if not logged in", async () => {
consoleError.mockReturnValueOnce();
const error = new HttpResponseError({ status: 401 } as any, {
code: 100003,
Expand Down Expand Up @@ -1931,6 +1992,7 @@ describe("Runtime", () => {
await getRuntime().bootstrap();
expect(window.APP_ROOT).toBe("sa-static/app-a/versions/1.0.0/webroot/");
});

test("loadBricks with union app mode", async () => {
window.STANDALONE_MICRO_APPS = true;
window.PUBLIC_DEPS = [
Expand Down
43 changes: 43 additions & 0 deletions packages/runtime/src/isNetworkError.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { HttpFetchError } from "@next-core/http";
import { BrickLoadError } from "@next-core/loader";
import { isNetworkError } from "./isNetworkError.js";

describe("isNetworkError", () => {
it("should return true for BrickLoadError", () => {
const error = new BrickLoadError("Brick load error");
expect(isNetworkError(error)).toBe(true);
});

it("should return true for HttpFetchError", () => {
const error = new HttpFetchError("Http fetch error");
expect(isNetworkError(error)).toBe(true);
});

it("should return true for ChunkLoadError", () => {
const error = new Error("Chunk load error");
error.name = "ChunkLoadError";
expect(isNetworkError(error)).toBe(true);
});

it("should return true for Event with error type and HTMLScriptElement target", () => {
const scriptElement = document.createElement("script");
const event = new Event("error");
Object.defineProperty(event, "target", { value: scriptElement });
expect(isNetworkError(event)).toBe(true);
});

it("should return false for other errors", () => {
const error = new Error("Some other error");
expect(isNetworkError(error)).toBe(false);
});

it("should return false for null or undefined", () => {
expect(isNetworkError(null)).toBe(false);
expect(isNetworkError(undefined)).toBe(false);
});

it("should return false for non-error objects", () => {
const error = { message: "Not an error instance" };
expect(isNetworkError(error)).toBe(false);
});
});
14 changes: 14 additions & 0 deletions packages/runtime/src/isNetworkError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { HttpFetchError } from "@next-core/http";
import { BrickLoadError } from "@next-core/loader";

export function isNetworkError(error: unknown): boolean {
return (
!!error &&
(error instanceof BrickLoadError ||
error instanceof HttpFetchError ||
(error instanceof Error && error.name === "ChunkLoadError") ||
(error instanceof Event &&
error.type === "error" &&
error.target instanceof HTMLScriptElement))
);
}
52 changes: 52 additions & 0 deletions packages/runtime/src/shouldReloadForError.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { shouldReloadForError } from "./shouldReloadForError.js";
import { BrickLoadError } from "@next-core/loader";

const mockGetItem = jest.spyOn(Storage.prototype, "getItem");
const mockSetItem = jest.spyOn(Storage.prototype, "setItem");
const mockRemoveItem = jest.spyOn(Storage.prototype, "removeItem");

describe("shouldReloadForError", () => {
beforeEach(() => {
Object.defineProperty(navigator, "userAgent", {
value: "upchat",
configurable: true,
});
mockGetItem.mockReturnValue(null);
});

it("should reload if error is a network error and count is less than MAX_RELOAD_COUNT", () => {
const result = shouldReloadForError(new BrickLoadError("Network error"));

expect(result).toBe(true);
expect(mockSetItem).toHaveBeenCalledWith("reload-for-error-count", "1");
});

it("should not reload if count is equal to MAX_RELOAD_COUNT", () => {
mockGetItem.mockReturnValue("2");

const result = shouldReloadForError(new BrickLoadError("Network error"));

expect(result).toBe(false);
expect(mockSetItem).not.toHaveBeenCalled();
expect(mockRemoveItem).toHaveBeenCalledWith("reload-for-error-count");
});

it("should not reload if userAgent does not match", () => {
Object.defineProperty(navigator, "userAgent", {
value: "Chrome",
configurable: true,
});

const result = shouldReloadForError(new BrickLoadError("Network error"));

expect(result).toBe(false);
expect(mockSetItem).not.toHaveBeenCalled();
});

it("should not reload if error is not a network error", () => {
const result = shouldReloadForError(new Error("Other error"));

expect(result).toBe(false);
expect(mockSetItem).not.toHaveBeenCalled();
});
});
Loading

0 comments on commit c91b3d2

Please sign in to comment.