This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5847-ed22158534c1ae503fea9b4338a03862b5231c97 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 44df4f77013208248c0e5e59d7d7b505ec04ad56 Author: Matthew B. <[email protected]> AuthorDate: Sun Jun 21 19:24:20 2026 -0700 feat(frontend): recover from ChunkLoadError with a guarded reload (#5847) ### What changes were proposed in this PR? - Add GlobalErrorHandler (implements Angular ErrorHandler), registered as the global ErrorHandler in app.module.ts. - handleError reloads the page once on a chunk-load failure (ChunkLoadError, "Loading chunk ... failed", or a failed dynamic import), guarded by a sessionStorage timestamp (10s window) so a genuinely missing chunk cannot cause a reload loop; all other errors delegate to Angular's default handler. - Chunk detection lives in a pure exported isChunkLoadError(error) function so it is unit-testable in isolation. ### Any related issues, documentation, discussions? Closes: #5837 ### How was this PR tested? - Run `yarn test --include='**/global-error-handler.service.spec.ts'` from frontend/, expect 5 passing cases: isChunkLoadError true for chunk errors and false for generic/TypeError/null; handleError reloads once and records the guard, does not reload again within the window, and does not reload on a non-chunk error. - Manual: load the app, in DevTools block a chunk request URL (Network, Block request URL) and trigger a navigation that loads it, expect a single automatic reload rather than a broken view; trigger it again immediately and expect no reload loop. ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Opus 4.8 in compliance with ASF --- frontend/src/app/app.module.ts | 4 +- .../global-error-handler.service.spec.ts | 168 +++++++++++++++++++++ .../global-error-handler.service.ts | 65 ++++++++ 3 files changed, 236 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 35e82f81b7..bf643be12d 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -20,7 +20,7 @@ import { DatePipe, registerLocaleData } from "@angular/common"; import { HTTP_INTERCEPTORS, HttpClientModule } from "@angular/common/http"; import en from "@angular/common/locales/en"; -import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core"; +import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, ErrorHandler, NgModule } from "@angular/core"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { BrowserModule } from "@angular/platform-browser"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; @@ -53,6 +53,7 @@ import { NullTypeComponent } from "./common/formly/null.type"; import { ObjectTypeComponent } from "./common/formly/object.type"; import { UserService } from "./common/service/user/user.service"; import { GuiConfigService } from "./common/service/gui-config.service"; +import { GlobalErrorHandler } from "./common/service/global-error-handler/global-error-handler.service"; import { DashboardComponent } from "./dashboard/component/dashboard.component"; import { UserWorkflowComponent } from "./dashboard/component/user/user-workflow/user-workflow.component"; import { ShareAccessComponent } from "./dashboard/component/user/share-access/share-access.component"; @@ -369,6 +370,7 @@ registerLocaleData(en); UserVenvComponent, ], providers: [ + { provide: ErrorHandler, useClass: GlobalErrorHandler }, provideNzI18n(en_US), AuthGuardService, AdminGuardService, diff --git a/frontend/src/app/common/service/global-error-handler/global-error-handler.service.spec.ts b/frontend/src/app/common/service/global-error-handler/global-error-handler.service.spec.ts new file mode 100644 index 0000000000..fb224c87a2 --- /dev/null +++ b/frontend/src/app/common/service/global-error-handler/global-error-handler.service.spec.ts @@ -0,0 +1,168 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ErrorHandler } from "@angular/core"; +import { GlobalErrorHandler, RELOAD_GUARD_KEY, isChunkLoadError } from "./global-error-handler.service"; + +// Records reloads instead of navigating, so the guard logic is observable. +class TestableGlobalErrorHandler extends GlobalErrorHandler { + public reloadCount = 0; + protected override reload(): void { + this.reloadCount++; + } +} + +describe("isChunkLoadError", () => { + it("detects chunk-load failures", () => { + expect(isChunkLoadError({ name: "ChunkLoadError" })).toBe(true); + expect(isChunkLoadError(new Error("Loading chunk 5 failed."))).toBe(true); + expect(isChunkLoadError(new Error("Failed to fetch dynamically imported module: http://x/y.js"))).toBe(true); + expect(isChunkLoadError("ChunkLoadError: Loading chunk vendors failed")).toBe(true); + }); + + it("detects chunk-load failures from a plain object message", () => { + expect(isChunkLoadError({ message: "Loading chunk 12 failed." })).toBe(true); + }); + + it("matches case-insensitively", () => { + expect(isChunkLoadError("CHUNKLOADERROR")).toBe(true); + expect(isChunkLoadError(new Error("Error loading Dynamically Imported Module"))).toBe(true); + }); + + it("ignores unrelated errors", () => { + expect(isChunkLoadError(new Error("something broke"))).toBe(false); + expect(isChunkLoadError(new TypeError("x is not a function"))).toBe(false); + expect(isChunkLoadError(null)).toBe(false); + expect(isChunkLoadError(undefined)).toBe(false); + expect(isChunkLoadError({})).toBe(false); + }); + + it("ignores errors whose name matches loosely but is not ChunkLoadError", () => { + expect(isChunkLoadError({ name: "TypeError", message: "boom" })).toBe(false); + }); + + it("ignores values with a non-string message and non-string body", () => { + expect(isChunkLoadError({ message: 42 })).toBe(false); + expect(isChunkLoadError({ message: { nested: "Loading chunk 1 failed." } })).toBe(false); + expect(isChunkLoadError(1234)).toBe(false); + expect(isChunkLoadError(true)).toBe(false); + }); +}); + +describe("GlobalErrorHandler", () => { + let handler: TestableGlobalErrorHandler; + let defaultHandlerSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + sessionStorage.clear(); + // Suppress (and observe) the Angular default handler's console.error. + defaultHandlerSpy = vi.spyOn(ErrorHandler.prototype, "handleError").mockImplementation(() => {}); + handler = new TestableGlobalErrorHandler(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + }); + + it("reloads once on a chunk-load error and records the guard", () => { + handler.handleError(new Error("Loading chunk 3 failed.")); + expect(handler.reloadCount).toBe(1); + expect(sessionStorage.getItem(RELOAD_GUARD_KEY)).not.toBeNull(); + }); + + it("does not forward a recovered chunk error to the default handler", () => { + handler.handleError(new Error("Loading chunk 3 failed.")); + expect(defaultHandlerSpy).not.toHaveBeenCalled(); + }); + + it("does not reload again within the guard window", () => { + handler.handleError(new Error("Loading chunk 3 failed.")); + handler.handleError(new Error("Loading chunk 3 failed.")); + expect(handler.reloadCount).toBe(1); + }); + + it("forwards a chunk error to the default handler once reload is guarded", () => { + handler.handleError(new Error("Loading chunk 3 failed.")); // reloads, no forward + handler.handleError(new Error("Loading chunk 3 failed.")); // guarded -> forwards + expect(handler.reloadCount).toBe(1); + expect(defaultHandlerSpy).toHaveBeenCalledTimes(1); + }); + + it("does not reload on a non-chunk error and forwards it to the default handler", () => { + const error = new Error("totally unrelated"); + handler.handleError(error); + expect(handler.reloadCount).toBe(0); + expect(defaultHandlerSpy).toHaveBeenCalledWith(error); + }); + + it("reloads again once the guard window has elapsed", () => { + // A guard timestamp far in the past is treated as expired. + sessionStorage.setItem(RELOAD_GUARD_KEY, "1000"); + handler.handleError(new Error("Loading chunk 3 failed.")); + expect(handler.reloadCount).toBe(1); + }); + + it("does not reload when a fresh guard timestamp is present", () => { + sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now())); + handler.handleError(new Error("Loading chunk 3 failed.")); + expect(handler.reloadCount).toBe(0); + }); + + it("reloads when the stored guard value is not a usable number", () => { + sessionStorage.setItem(RELOAD_GUARD_KEY, "not-a-number"); + handler.handleError(new Error("Loading chunk 3 failed.")); + expect(handler.reloadCount).toBe(1); + }); + + it("reloads when the stored guard value is non-positive", () => { + sessionStorage.setItem(RELOAD_GUARD_KEY, "0"); + handler.handleError(new Error("Loading chunk 3 failed.")); + expect(handler.reloadCount).toBe(1); + }); +}); + +describe("GlobalErrorHandler.reload (default implementation)", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + }); + + it("delegates to window.location.reload on a chunk-load error", () => { + // jsdom marks window.location.reload non-configurable, so swap the + // whole location object for one with a stubbed reload, then restore it. + const originalLocation = window.location; + const reloadMock = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...originalLocation, reload: reloadMock }, + }); + try { + const handler = new GlobalErrorHandler(); + handler.handleError(new Error("Loading chunk 7 failed.")); + expect(reloadMock).toHaveBeenCalledTimes(1); + } finally { + Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); + } + }); +}); diff --git a/frontend/src/app/common/service/global-error-handler/global-error-handler.service.ts b/frontend/src/app/common/service/global-error-handler/global-error-handler.service.ts new file mode 100644 index 0000000000..a485882646 --- /dev/null +++ b/frontend/src/app/common/service/global-error-handler/global-error-handler.service.ts @@ -0,0 +1,65 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ErrorHandler, Injectable } from "@angular/core"; + +const CHUNK_LOAD_ERROR = /chunkloaderror|loading chunk [^ ]+ failed|dynamically imported module/i; +export const RELOAD_GUARD_KEY = "texera-chunk-reload-at"; +const RELOAD_GUARD_WINDOW_MS = 10_000; + +// True for a failed JS chunk / dynamic-import load. +export function isChunkLoadError(error: unknown): boolean { + if (error == null) { + return false; + } + if ((error as { name?: unknown }).name === "ChunkLoadError") { + return true; + } + const message = (error as { message?: unknown }).message; + const text = typeof message === "string" ? message : typeof error === "string" ? error : ""; + return CHUNK_LOAD_ERROR.test(text); +} + +@Injectable() +export class GlobalErrorHandler implements ErrorHandler { + private readonly defaultHandler = new ErrorHandler(); + + handleError(error: unknown): void { + if (isChunkLoadError(error) && this.tryReload()) { + return; + } + this.defaultHandler.handleError(error); + } + + // Reload at most once per guard window so a missing chunk cannot loop. + private tryReload(): boolean { + const now = Date.now(); + const last = Number(sessionStorage.getItem(RELOAD_GUARD_KEY)); + if (Number.isFinite(last) && last > 0 && now - last < RELOAD_GUARD_WINDOW_MS) { + return false; + } + sessionStorage.setItem(RELOAD_GUARD_KEY, String(now)); + this.reload(); + return true; + } + + protected reload(): void { + window.location.reload(); + } +}
