This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 44df4f7701 feat(frontend): recover from ChunkLoadError with a guarded
reload (#5847)
44df4f7701 is described below
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();
+ }
+}