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();
+  }
+}

Reply via email to