This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 9d4cdd7802a UI: Reduce initial JavaScript payload (#74663)
9d4cdd7802a is described below

commit 9d4cdd7802a7023ceda56017bb4d276dd59dafa1
Author: safaehar <[email protected]>
AuthorDate: Tue Nov 3 16:03:30 2026 -0500

    UI: Reduce initial JavaScript payload (#74663)
    
    * UI: Reduce initial JavaScript payload
    
    Airflow currently loads code for every UI page before rendering the active 
route, increasing download, parse, and execution work during startup.
    
    * UI: Show progress during route navigation
    
    Cold route chunks can take time to arrive on slow connections, leaving 
navigation without visible feedback.
    
    * UI: Recover from failed lazy route loading
    
    Browsers can retain an older entrypoint across a deployment and request 
route chunks that no longer exist, leaving navigation stuck on an unrecoverable 
error.
    
    * UI: Fix stale route chunk recovery
    
    * UI: Scope lazy route test setup
    
    * UI: Keep lazy route tests stable
---
 .../airflow/ui/public/i18n/locales/en/common.json  |   1 +
 .../src/airflow/ui/src/layouts/BaseLayout.test.tsx |  23 +-
 .../src/airflow/ui/src/layouts/BaseLayout.tsx      |  18 +-
 .../ui/src/pages/Asset/AssetEventsFilters.test.tsx |   6 +-
 .../src/airflow/ui/src/pages/Asset/Header.test.tsx |   6 +-
 .../ui/src/pages/AssetsList/AssetsList.test.tsx    |   6 +-
 .../Dag/Backfills/BackfillsDateFilter.test.tsx     |   6 +-
 .../ui/src/pages/Dag/RequiredActionsRoute.test.tsx |   6 +-
 .../ui/src/pages/DagBundles/DagBundles.test.tsx    |   6 +-
 .../airflow/ui/src/pages/DagRuns/DagRuns.test.tsx  |   6 +-
 .../DagsList/DagsFilters/DagsFilters.test.tsx      |  26 +-
 .../ui/src/pages/DagsList/DagsList.test.tsx        |   6 +-
 .../src/airflow/ui/src/pages/Error.test.tsx        | 100 +++++++
 airflow-core/src/airflow/ui/src/pages/Error.tsx    |  13 +
 .../ui/src/pages/TaskInstance/Logs/Logs.test.tsx   |   3 +
 .../pages/TaskInstance/Logs/LogsSelection.test.tsx |   3 +
 .../TaskInstances/TaskInstancesFilter.test.tsx     |   6 +-
 .../src/airflow/ui/src/pages/XCom/XCom.test.tsx    |   6 +-
 .../airflow/ui/src/pages/documentTitle.test.tsx    |  62 ++--
 airflow-core/src/airflow/ui/src/router.test.tsx    |  71 ++++-
 airflow-core/src/airflow/ui/src/router.tsx         | 316 ++++++++++++---------
 .../src/airflow/ui/src/utils/AppWrapper.tsx        |  16 +-
 .../src/airflow/ui/src/utils/routeModule.ts        |  45 +++
 23 files changed, 564 insertions(+), 193 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 927b82cf46e..9d47b4b177d 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -118,6 +118,7 @@
     "home": "Home",
     "invalidUrl": "Page Not Found. Please check the URL and try again.",
     "notFound": "Page Not Found",
+    "reload": "Reload page",
     "title": "Error"
   },
   "errors": {
diff --git a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.test.tsx
index e00f7577071..b4418377686 100644
--- a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.test.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { render, waitFor } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 
 import { useDocumentTitle } from "src/utils";
@@ -27,6 +27,7 @@ import { BaseLayout } from "./BaseLayout";
 const mockConfig: Record<string, unknown> = {
   instance_name: "Market Data Dev (SpectroCloud)",
 };
+const mockNavigation = vi.hoisted(() => ({ state: "idle" }));
 
 vi.mock("openapi/queries", () => ({
   usePluginServiceGetPlugins: () => ({ data: { plugins: [] } }),
@@ -49,6 +50,11 @@ vi.mock("react-i18next", () => ({
   }),
 }));
 
+vi.mock("react-router-dom", async (importOriginal) => ({
+  ...(await importOriginal()),
+  useNavigation: () => mockNavigation,
+}));
+
 vi.mock("./Nav", () => ({
   Nav: () => <nav />,
 }));
@@ -63,6 +69,7 @@ describe("BaseLayout", () => {
   beforeEach(() => {
     document.title = "Airflow";
     mockConfig.instance_name = "Market Data Dev (SpectroCloud)";
+    mockNavigation.state = "idle";
   });
 
   afterEach(() => {
@@ -108,4 +115,18 @@ describe("BaseLayout", () => {
 
     await waitFor(() => expect(document.title).toBe("Airflow"));
   });
+
+  it.each([
+    ["idle", false],
+    ["loading", true],
+    ["submitting", true],
+  ])("sets navigation progress visibility when the router is %s", (state, 
isVisible) => {
+    mockNavigation.state = state;
+
+    render(<BaseLayout />, { wrapper: Wrapper });
+
+    expect(getComputedStyle(screen.getByLabelText("loading")).visibility).toBe(
+      isVisible ? "visible" : "hidden",
+    );
+  });
 });
diff --git a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx 
b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
index bddf353e7f4..a5d7e20f394 100644
--- a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
@@ -20,11 +20,13 @@ import { useEffect, type PropsWithChildren } from "react";
 
 import { Box, LocaleProvider } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
-import { Outlet } from "react-router-dom";
+import { Outlet, useNavigation } from "react-router-dom";
 
 import { usePluginServiceGetPlugins } from "openapi/queries";
 import type { ReactAppResponse } from "openapi/requests/types.gen";
 
+import { ProgressBar } from "src/system-components";
+
 import { ReactPlugin } from "src/pages/ReactPlugin";
 
 import { KeyboardShortcutsModal } from "src/components/KeyboardShortcuts";
@@ -36,7 +38,8 @@ import { DocumentTitleProvider } from "src/utils";
 import { Nav } from "./Nav";
 
 export const BaseLayout = ({ children }: PropsWithChildren) => {
-  const { i18n } = useTranslation();
+  const { i18n, t: translate } = useTranslation();
+  const navigation = useNavigation();
   const { data: pluginData } = usePluginServiceGetPlugins();
   const theme = useConfig("theme") as unknown as { icon?: string; 
icon_dark_mode?: string } | undefined;
 
@@ -110,6 +113,17 @@ export const BaseLayout = ({ children }: 
PropsWithChildren) => {
     <LocaleProvider locale={i18n.language || "en"}>
       <DocumentTitleProvider>
         <Box display="flex" flexDirection="column" h="100vh">
+          <ProgressBar
+            aria-label={translate("loading")}
+            insetInlineEnd={0}
+            insetInlineStart={16}
+            pointerEvents="none"
+            position="fixed"
+            size="xs"
+            top={0}
+            visibility={navigation.state === "idle" ? "hidden" : "visible"}
+            zIndex="toast"
+          />
           <KeyboardShortcutsModal />
           <Nav />
           <Box
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Asset/AssetEventsFilters.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetEventsFilters.test.tsx
index 905e11c3366..8545b40cf2e 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/AssetEventsFilters.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetEventsFilters.test.tsx
@@ -18,9 +18,11 @@
  */
 import "@testing-library/jest-dom/vitest";
 import { render, screen, waitFor } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { beforeAll, describe, expect, it } from "vitest";
 
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
+
+beforeAll(() => preloadAppRoutes(["/assets/1"]));
 
 // The assets mock handler serves two events for asset 1:
 //   id 100 — producer_dag,           run manual__2025-01-10,    map index -1, 
no partition key
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/Header.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Asset/Header.test.tsx
index 316eb2f226e..098d6b18612 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/Header.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/Header.test.tsx
@@ -18,9 +18,11 @@
  */
 import "@testing-library/jest-dom/vitest";
 import { render, screen, waitFor } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { beforeAll, describe, expect, it } from "vitest";
 
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
+
+beforeAll(() => preloadAppRoutes(["/assets/1"]));
 
 // The assets mock handler (see src/mocks/handlers/assets.ts) serves asset 1 
with one
 // alias and one watcher, and asset 2 with neither.
diff --git 
a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
index 12fc4fb295e..7a31ed061c8 100644
--- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
@@ -19,10 +19,12 @@
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor } from "@testing-library/react";
 import { delay, http, HttpResponse } from "msw";
-import { describe, expect, it } from "vitest";
+import { beforeAll, describe, expect, it } from "vitest";
 
 import { server } from "src/mocks/server";
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
+
+beforeAll(() => preloadAppRoutes(["/assets"]));
 
 // The assets mock handler (see src/mocks/handlers/assets.ts) returns a single 
asset
 // with one consuming task, one alias and one watcher.
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/BackfillsDateFilter.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/BackfillsDateFilter.test.tsx
index 228b423d3d8..ae9ac534813 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/BackfillsDateFilter.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/BackfillsDateFilter.test.tsx
@@ -18,9 +18,11 @@
  */
 import "@testing-library/jest-dom/vitest";
 import { render, screen, waitFor, within } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { beforeAll, describe, expect, it } from "vitest";
 
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
+
+beforeAll(() => preloadAppRoutes(["/dags/tutorial_taskflow_api/backfills"]));
 
 // The backfills mock handler (see src/mocks/handlers/backfills.ts) returns:
 //   - id 1 (start_date: 2024-12-01, end_date: 2024-12-31) — excluded when 
filtering Jan 2025
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/RequiredActionsRoute.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/RequiredActionsRoute.test.tsx
index 677a0b68db0..34446bf91ed 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/RequiredActionsRoute.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/RequiredActionsRoute.test.tsx
@@ -19,13 +19,15 @@
 import "@testing-library/jest-dom/vitest";
 import { render, screen, waitFor } from "@testing-library/react";
 import { http, HttpResponse } from "msw";
-import { beforeEach, describe, expect, it } from "vitest";
+import { beforeAll, beforeEach, describe, expect, it } from "vitest";
 
 import { server } from "src/mocks/server";
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
 
 const hitlRequests: Array<{ dagId: string; dagRunId: string }> = [];
 
+beforeAll(() => 
preloadAppRoutes(["/dags/tutorial_taskflow_api/required_actions"]));
+
 beforeEach(() => {
   server.use(
     http.get("/api/v2/dags/:dagId/dagRuns/:dagRunId/hitlDetails", ({ params }) 
=> {
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagBundles/DagBundles.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagBundles/DagBundles.test.tsx
index 4e0c92e591d..225fa182295 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagBundles/DagBundles.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagBundles/DagBundles.test.tsx
@@ -19,10 +19,12 @@
 import "@testing-library/jest-dom/vitest";
 import { act, render, screen, waitFor } from "@testing-library/react";
 import { http, HttpResponse } from "msw";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
"vitest";
 
 import { server } from "src/mocks/server";
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
+
+beforeAll(() => preloadAppRoutes(["/dag_bundles"]));
 
 /** Requests the page has made to the bundles endpoint since the last reset. */
 let requestCount = 0;
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx
index ef4b70da9b7..909cf2d2b5e 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx
@@ -18,9 +18,11 @@
  */
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor, within } from 
"@testing-library/react";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
 
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
+
+beforeAll(() => preloadAppRoutes(["/dag_runs"]));
 
 // Stand in for the Monaco-backed JSON viewer so the test can assert the 
collapse
 // state without loading the editor.
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
index b6fa3491adc..cb8e1e7c24d 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
@@ -18,11 +18,13 @@
  */
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { afterEach, describe, it, expect, vi } from "vitest";
+import { afterEach, beforeAll, describe, it, expect, vi } from "vitest";
 
 import { brokenDag, dagsListHandler, defaultDags, drainingDag } from 
"src/mocks/handlers/dags";
 import { server } from "src/mocks/server";
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
+
+beforeAll(() => preloadAppRoutes(["/dags"]));
 
 const mockConfig: Record<string, unknown> = {
   auto_refresh_interval: 3,
@@ -51,7 +53,7 @@ const addFilter = async (key: string) => {
 };
 
 const selectPillOption = async (key: string, value: string) => {
-  fireEvent.click(screen.getByTestId(`${key}-pill`));
+  fireEvent.click(await screen.findByTestId(`${key}-pill`));
   fireEvent.click(await screen.findByTestId(`${key}-filter`));
   fireEvent.click(await screen.findByTestId(`${key}-filter-${value}`));
 };
@@ -64,16 +66,20 @@ describe("Paused filter with hide_paused_dags_by_default 
enabled", () => {
   it("defaults to showing only active dags", async () => {
     render(<AppWrapper initialEntries={["/dags"]} />);
 
-    await waitFor(() => 
expect(screen.getByText("tutorial_taskflow_api_success")).toBeInTheDocument());
-    expect(screen.queryByText("paused_dag")).not.toBeInTheDocument();
+    await waitFor(() => {
+      
expect(screen.getByText("tutorial_taskflow_api_success")).toBeInTheDocument();
+      expect(screen.queryByText("paused_dag")).not.toBeInTheDocument();
+    });
   });
 
   it("keeps draining dags under the default, since they are not paused yet", 
async () => {
     withDrainingDag();
     render(<AppWrapper initialEntries={["/dags"]} />);
 
-    await waitFor(() => 
expect(screen.getByText("draining_dag")).toBeInTheDocument());
-    expect(screen.queryByText("paused_dag")).not.toBeInTheDocument();
+    await waitFor(() => {
+      expect(screen.getByText("draining_dag")).toBeInTheDocument();
+      expect(screen.queryByText("paused_dag")).not.toBeInTheDocument();
+    });
   });
 
   it("shows the default as a pill rather than filtering invisibly", async () 
=> {
@@ -112,8 +118,10 @@ describe("Paused filter with hide_paused_dags_by_default 
enabled", () => {
   it("shows all dags after removing the paused filter", async () => {
     render(<AppWrapper initialEntries={["/dags"]} />);
 
-    await waitFor(() => 
expect(screen.getByText("tutorial_taskflow_api_success")).toBeInTheDocument());
-    expect(screen.queryByText("paused_dag")).not.toBeInTheDocument();
+    await waitFor(() => {
+      
expect(screen.getByText("tutorial_taskflow_api_success")).toBeInTheDocument();
+      expect(screen.queryByText("paused_dag")).not.toBeInTheDocument();
+    });
 
     // There is no "All" option any more; removing the pill is how you ask for 
every Dag.
     fireEvent.click(screen.getByTestId("paused-pill-remove"));
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx
index 96c86fd3524..c8ce1be5770 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx
@@ -19,14 +19,16 @@
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor, within } from 
"@testing-library/react";
 import { delay, http, HttpResponse } from "msw";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
"vitest";
 
 import type { DagSchedulingState } from "openapi/requests/types.gen";
 
 import { DAGS_LIST_DISPLAY_KEY, DAGS_LIST_SHOW_RECENT_TASKS_KEY } from 
"src/constants/localStorage";
 import { failedDag, pausedDag, successDag } from "src/mocks/handlers/dags";
 import { server } from "src/mocks/server";
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
+
+beforeAll(() => preloadAppRoutes(["/dags"]));
 
 afterEach(() => {
   localStorage.clear();
diff --git a/airflow-core/src/airflow/ui/src/pages/Error.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Error.test.tsx
new file mode 100644
index 00000000000..efbda779f48
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Error.test.tsx
@@ -0,0 +1,100 @@
+/*!
+ * 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 "@testing-library/jest-dom/vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+import type * as ReactRouterDom from "react-router-dom";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import i18n from "src/i18n/config";
+import { BaseWrapper } from "src/utils/Wrapper";
+import { loadRouteModule } from "src/utils/routeModule";
+
+import commonLocale from "../../public/i18n/locales/en/common.json";
+import { ErrorPage } from "./Error";
+
+const { navigate, useRouteError } = vi.hoisted(() => ({
+  navigate: vi.fn(),
+  useRouteError: vi.fn(),
+}));
+
+vi.mock("react-router-dom", async (importOriginal) => ({
+  ...(await importOriginal<typeof ReactRouterDom>()),
+  useNavigate: () => navigate,
+  useRouteError,
+}));
+
+const renderErrorPage = () =>
+  render(
+    <MemoryRouter>
+      <ErrorPage />
+    </MemoryRouter>,
+    { wrapper: BaseWrapper },
+  );
+
+beforeEach(() => {
+  navigate.mockClear();
+  useRouteError.mockReset();
+  globalThis.sessionStorage.clear();
+  i18n.addResourceBundle("en", "common", commonLocale, true, true);
+});
+
+afterEach(() => {
+  vi.restoreAllMocks();
+  globalThis.sessionStorage.clear();
+});
+
+describe("ErrorPage", () => {
+  it("offers a manual reload after a route error", () => {
+    const reload = vi.spyOn(globalThis.location, 
"reload").mockImplementation(() => undefined);
+
+    useRouteError.mockReturnValue(new Error("Route module unavailable"));
+    renderErrorPage();
+
+    expect(reload).not.toHaveBeenCalled();
+
+    fireEvent.click(screen.getByRole("button", { name: "Reload page" }));
+
+    expect(reload).toHaveBeenCalledOnce();
+  });
+
+  it("reloads the committed route only once after route module loading fails", 
async () => {
+    const error = new Error("Route module unavailable");
+    const reload = vi.spyOn(globalThis.location, 
"reload").mockImplementation(() => undefined);
+
+    useRouteError.mockReturnValue(error);
+    await expect(loadRouteModule(() => 
Promise.reject(error))).rejects.toBe(error);
+    const firstRender = renderErrorPage();
+
+    expect(reload).toHaveBeenCalledOnce();
+
+    firstRender.unmount();
+    await expect(loadRouteModule(() => 
Promise.reject(error))).rejects.toBe(error);
+    renderErrorPage();
+
+    expect(reload).toHaveBeenCalledOnce();
+  });
+
+  it("does not offer a reload for an unmatched route", () => {
+    useRouteError.mockReturnValue(undefined);
+    renderErrorPage();
+
+    expect(screen.queryByRole("button", { name: "Reload page" 
})).not.toBeInTheDocument();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Error.tsx 
b/airflow-core/src/airflow/ui/src/pages/Error.tsx
index cf378be3454..85b827e19ba 100644
--- a/airflow-core/src/airflow/ui/src/pages/Error.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Error.tsx
@@ -16,17 +16,25 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import { useEffect } from "react";
+
 import { Box, VStack, Heading, Text, Button, Container, HStack, Code } from 
"@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 import { useNavigate, useRouteError, isRouteErrorResponse } from 
"react-router-dom";
 
 import { Logo } from "src/components/Logo";
 
+import { reloadAfterFailedRouteModuleLoad } from "src/utils/routeModule";
+
 export const ErrorPage = () => {
   const navigate = useNavigate();
   const error = useRouteError();
   const { t: translate } = useTranslation();
 
+  useEffect(() => {
+    reloadAfterFailedRouteModuleLoad();
+  }, [error]);
+
   let errorMessage = translate("error.defaultMessage");
   let statusCode = "";
 
@@ -81,6 +89,11 @@ export const ErrorPage = () => {
             >
               {translate("error.home")}
             </Button>
+            {error === null || error === undefined ? undefined : (
+              <Button onClick={() => globalThis.location.reload()} size="lg" 
variant="outline">
+                {translate("error.reload")}
+              </Button>
+            )}
           </HStack>
         </VStack>
       </Container>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
index d9a6f01946c..47f62ec39cb 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
@@ -20,6 +20,9 @@ import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor } from "@testing-library/react";
 import { afterEach, beforeAll, describe, expect, it } from "vitest";
 
+// Resolve the route module during test collection, outside the per-test 
timeout.
+import "src/pages/TaskInstance";
+
 import { AppWrapper } from "src/utils/AppWrapper";
 
 const ITEM_HEIGHT = 20;
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogsSelection.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogsSelection.test.tsx
index 336c31c66e1..47d2bbdd5ff 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogsSelection.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogsSelection.test.tsx
@@ -20,6 +20,9 @@ import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor } from "@testing-library/react";
 import { beforeAll, describe, expect, it, vi } from "vitest";
 
+// Resolve the route module during test collection, outside the per-test 
timeout.
+import "src/pages/TaskInstance";
+
 import { AppWrapper } from "src/utils/AppWrapper";
 
 const ITEM_HEIGHT = 20;
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.test.tsx
index 4a3da0d5bd4..1d75bc8cb08 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.test.tsx
@@ -18,9 +18,11 @@
  */
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { beforeAll, describe, expect, it } from "vitest";
 
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
+
+beforeAll(() => preloadAppRoutes(["/task_instances"]));
 
 describe("Task Instances date filters", () => {
   it("shows a pill for a logical_date link so the filter is visible and 
removable", async () => {
diff --git a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.test.tsx
index 1cf19e43b00..7e2f9f2b3ca 100644
--- a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.test.tsx
@@ -19,11 +19,13 @@
 import "@testing-library/jest-dom/vitest";
 import { fireEvent, render, screen, waitFor } from "@testing-library/react";
 import { http, HttpResponse } from "msw";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
 
 import type * as ColorMode from "src/context/colorMode";
 import { server } from "src/mocks/server";
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
+
+beforeAll(() => preloadAppRoutes(["/xcoms"]));
 
 vi.mock("src/components/MonacoEditor", () => ({
   default: ({ value }: { readonly value?: string }) => <div 
data-testid="monaco-editor">{value}</div>,
diff --git a/airflow-core/src/airflow/ui/src/pages/documentTitle.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/documentTitle.test.tsx
index 5ebaef2648c..d9b70f02c19 100644
--- a/airflow-core/src/airflow/ui/src/pages/documentTitle.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/documentTitle.test.tsx
@@ -17,9 +17,9 @@
  * under the License.
  */
 import { render, waitFor } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
 
-import { AppWrapper } from "src/utils/AppWrapper";
+import { AppWrapper, preloadAppRoutes } from "src/utils/AppWrapper";
 
 // The detail pages open a task-instance summaries stream that would otherwise 
stay active when the
 // tiny title-only assertion resolves and the tree unmounts. Stub it so no 
stream is left open.
@@ -32,41 +32,49 @@ vi.mock("src/queries/useGridTISummaries.ts", () => ({
 // each page wires the intended translation key into the browser tab title.
 const INSTANCE = "Airflow";
 
+const PAGE_ROUTES = [
+  ["/dags", "nav.dags"],
+  ["/assets", "nav.assets"],
+  ["/dag_runs", "dagRun_other"],
+  ["/task_instances", "taskInstance_other"],
+  ["/connections", "admin.Connections"],
+  ["/variables", "admin.Variables"],
+  ["/pools", "admin.Pools"],
+  ["/providers", "admin.Providers"],
+  ["/plugins", "admin.Plugins"],
+  ["/configs", "admin.Config"],
+  ["/dag_bundles", "browse.dagBundles"],
+  ["/deadlines", "browse.deadlines"],
+  ["/jobs", "browse.jobs"],
+  ["/xcoms", "browse.xcoms"],
+  ["/events", "browse.auditLog"],
+  ["/required_actions", "browse.requiredActions"],
+  ["/security/users", "nav.security"],
+] as const;
+
+const DETAIL_ROUTES = [
+  ["/dags/log_grouping/runs/manual__2025-02-18T12:19", 
"manual__2025-02-18T12:19"],
+  ["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source", 
"log_source"],
+  // Nested list/audit tabs are gated, so the detail page keeps ownership of 
the title.
+  ["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source/events", 
"log_source"],
+] as const;
+
+beforeAll(() =>
+  preloadAppRoutes([...PAGE_ROUTES.map(([route]) => route), 
...DETAIL_ROUTES.map(([route]) => route)]),
+);
+
 describe("document title", () => {
   beforeEach(() => {
     document.title = "";
   });
 
-  it.each([
-    ["/dags", "nav.dags"],
-    ["/assets", "nav.assets"],
-    ["/dag_runs", "dagRun_other"],
-    ["/task_instances", "taskInstance_other"],
-    ["/connections", "admin.Connections"],
-    ["/variables", "admin.Variables"],
-    ["/pools", "admin.Pools"],
-    ["/providers", "admin.Providers"],
-    ["/plugins", "admin.Plugins"],
-    ["/configs", "admin.Config"],
-    ["/dag_bundles", "browse.dagBundles"],
-    ["/deadlines", "browse.deadlines"],
-    ["/jobs", "browse.jobs"],
-    ["/xcoms", "browse.xcoms"],
-    ["/events", "browse.auditLog"],
-    ["/required_actions", "browse.requiredActions"],
-    ["/security/users", "nav.security"],
-  ])("shows the page name before the instance name on %s", async (route, 
pageTitle) => {
+  it.each(PAGE_ROUTES)("shows the page name before the instance name on %s", 
async (route, pageTitle) => {
     render(<AppWrapper initialEntries={[route]} />);
 
     await waitFor(() => expect(document.title).toBe(`${pageTitle} - 
${INSTANCE}`));
   });
 
-  it.each([
-    ["/dags/log_grouping/runs/manual__2025-02-18T12:19", 
"manual__2025-02-18T12:19"],
-    ["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source", 
"log_source"],
-    // Nested list/audit tabs are gated, so the detail page keeps ownership of 
the title.
-    
["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source/events", 
"log_source"],
-  ])("uses the entity identifier for detail page %s", async (route, pageTitle) 
=> {
+  it.each(DETAIL_ROUTES)("uses the entity identifier for detail page %s", 
async (route, pageTitle) => {
     render(<AppWrapper initialEntries={[route]} />);
 
     await waitFor(() => expect(document.title).toBe(`${pageTitle} - 
${INSTANCE}`));
diff --git a/airflow-core/src/airflow/ui/src/router.test.tsx 
b/airflow-core/src/airflow/ui/src/router.test.tsx
index 2598dff5d14..f9382cfe54e 100644
--- a/airflow-core/src/airflow/ui/src/router.test.tsx
+++ b/airflow-core/src/airflow/ui/src/router.test.tsx
@@ -16,14 +16,20 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { matchRoutes } from "react-router-dom";
-import { describe, expect, it } from "vitest";
+import { matchRoutes, type LazyRouteFunction, type RouteObject } from 
"react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 
 import { TabEntity, TaskInstanceTab } from "src/constants/tab";
+import { loadRouteModule, reloadAfterFailedRouteModuleLoad } from 
"src/utils/routeModule";
 import { getTabPath } from "src/utils/tab";
 
 import { routerConfig, taskInstanceRoutes } from "./router";
 
+vi.mock("src/pages/DagsList", () => ({ DagsList: () => null }));
+
+const getDescendantRoutes = (routes: ReadonlyArray<RouteObject>): 
Array<RouteObject> =>
+  routes.flatMap((route) => [route, ...getDescendantRoutes(route.children ?? 
[])]);
+
 const getAdditionalPath = (pathname: string) => {
   const matches = matchRoutes(routerConfig, pathname) ?? [];
 
@@ -61,6 +67,67 @@ describe("Dag route handles", () => {
   });
 });
 
+describe("lazy routes", () => {
+  beforeEach(() => globalThis.sessionStorage.clear());
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+    globalThis.sessionStorage.clear();
+  });
+
+  it("lazy-loads every page route", () => {
+    const [rootRoute] = routerConfig as Array<RouteObject>;
+
+    if (rootRoute === undefined) {
+      throw new TypeError("Expected a root route");
+    }
+
+    const pageRoutes = getDescendantRoutes(rootRoute.children ?? 
[]).filter((route) => route.path !== "*");
+
+    expect(pageRoutes).not.toHaveLength(0);
+    expect(pageRoutes.every((route) => route.element === undefined && typeof 
route.lazy === "function")).toBe(
+      true,
+    );
+  });
+
+  it("loads a page component on demand", async () => {
+    const route = matchRoutes(routerConfig as Array<RouteObject>, 
"/dags")?.at(-1)?.route;
+
+    expect(route).not.toHaveProperty("element");
+
+    if (typeof route?.lazy !== "function") {
+      throw new TypeError("Expected the Dags list route to be lazy");
+    }
+
+    const loadRoute = route.lazy as LazyRouteFunction<RouteObject>;
+    const [{ Component }, { DagsList }] = await Promise.all([loadRoute(), 
import("src/pages/DagsList")]);
+
+    expect(Component).toBe(DagsList);
+  });
+
+  it("defers reloading until the route error page is rendered", async () => {
+    const error = new Error("Route module unavailable");
+    const reload = vi.spyOn(globalThis.location, 
"reload").mockImplementation(() => undefined);
+
+    await expect(loadRouteModule(() => 
Promise.reject(error))).rejects.toBe(error);
+
+    expect(reload).not.toHaveBeenCalled();
+  });
+
+  it("allows recovery again after a route module loads successfully", async () 
=> {
+    const error = new Error("Route module unavailable");
+    const reload = vi.spyOn(globalThis.location, 
"reload").mockImplementation(() => undefined);
+
+    await expect(loadRouteModule(() => 
Promise.reject(error))).rejects.toBe(error);
+    reloadAfterFailedRouteModuleLoad();
+    await expect(loadRouteModule(() => 
Promise.resolve("loaded"))).resolves.toBe("loaded");
+    await expect(loadRouteModule(() => 
Promise.reject(error))).rejects.toBe(error);
+    reloadAfterFailedRouteModuleLoad();
+
+    expect(reload).toHaveBeenCalledTimes(2);
+  });
+});
+
 type RouteLike = { readonly children?: ReadonlyArray<RouteLike>; readonly 
path?: string };
 
 describe("taskInstanceRoutes stay in sync with the TaskInstanceTab enum", () 
=> {
diff --git a/airflow-core/src/airflow/ui/src/router.tsx 
b/airflow-core/src/airflow/ui/src/router.tsx
index b7d43ad4115..08c71e633a6 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -16,6 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import type { ComponentType } from "react";
+
 import { queryOptions } from "@tanstack/react-query";
 import { createBrowserRouter } from "react-router-dom";
 
@@ -23,80 +25,140 @@ import { UseConfigServiceGetConfigsKeyFn } from 
"openapi/queries";
 import { ConfigService } from "openapi/requests/services.gen";
 
 import { BaseLayout } from "src/layouts/BaseLayout";
-import { DagsLayout } from "src/layouts/DagsLayout";
 
-import { Asset } from "src/pages/Asset";
-import { AssetEvents } from "src/pages/Asset/AssetEvents";
-import { AssetStateStore } from "src/pages/Asset/AssetStateStore";
-import { AssetsList } from "src/pages/AssetsList";
-import { Configs } from "src/pages/Configs";
-import { Connections } from "src/pages/Connections";
-import { Dag } from "src/pages/Dag";
-import { Backfills } from "src/pages/Dag/Backfills";
-import { Calendar } from "src/pages/Dag/Calendar/Calendar";
-import { Code } from "src/pages/Dag/Code";
-import { Details as DagDetails } from "src/pages/Dag/Details";
-import { Overview } from "src/pages/Dag/Overview";
-import { Tasks } from "src/pages/Dag/Tasks";
-import { Versions } from "src/pages/Dag/Versions";
-import { DagBundle } from "src/pages/DagBundle";
-import { DagBundles } from "src/pages/DagBundles";
-import { DagRuns } from "src/pages/DagRuns";
-import { DagsList } from "src/pages/DagsList";
-import { Dashboard } from "src/pages/Dashboard";
-import { Deadlines } from "src/pages/Deadlines";
 import { ErrorPage } from "src/pages/Error";
-import { Events } from "src/pages/Events";
-import { ExternalView } from "src/pages/ExternalView";
-import { GroupTaskInstance, GroupTaskInstances, LoopIterations } from 
"src/pages/GroupTaskInstance";
-import { HITLTaskInstances } from "src/pages/HITLTaskInstances";
-import { Jobs } from "src/pages/Jobs";
-import { LandingPage } from "src/pages/LandingPage";
-import { MappedTaskInstance } from "src/pages/MappedTaskInstance";
-import { Details as MappedTaskInstanceDetails } from 
"src/pages/MappedTaskInstance/Details";
-import { Plugins } from "src/pages/Plugins";
-import { Pools } from "src/pages/Pools";
-import { Providers } from "src/pages/Providers";
-import { Run } from "src/pages/Run";
-import { AssetEvents as DagRunAssetEvents } from "src/pages/Run/AssetEvents";
-import { Callbacks as DagRunCallbacks } from "src/pages/Run/Callbacks";
-import { Details as DagRunDetails } from "src/pages/Run/Details";
-import { Security } from "src/pages/Security";
-import { Settings } from "src/pages/Settings";
-import { Task } from "src/pages/Task";
-import { Overview as TaskOverview } from "src/pages/Task/Overview";
-import { TaskInstance, Logs } from "src/pages/TaskInstance";
-import { AssetEvents as TaskInstanceAssetEvents } from 
"src/pages/TaskInstance/AssetEvents";
-import { DefaultTab as TaskInstanceDefaultTab } from 
"src/pages/TaskInstance/DefaultTab";
-import { Details as TaskInstanceDetails } from 
"src/pages/TaskInstance/Details";
-import { HITLResponse } from "src/pages/TaskInstance/HITLResponse";
-import { RenderedTemplates } from "src/pages/TaskInstance/RenderedTemplates";
-import { TaskInstances } from "src/pages/TaskInstances";
-import { TaskStateStore } from "src/pages/TaskStateStore";
-import { Variables } from "src/pages/Variables";
-import { XCom } from "src/pages/XCom";
 
 import { TabEntity, TabName, TaskInstanceTab } from "src/constants/tab";
+import { loadRouteModule } from "src/utils/routeModule";
 
 import { client } from "./queryClient";
 
+const lazyRoute =
+  <Module,>(factory: () => Promise<Module>, name: keyof Module) =>
+  async () => {
+    const module = await loadRouteModule(factory);
+
+    return { Component: module[name] as ComponentType };
+  };
+
+const loadAsset = lazyRoute(() => import("src/pages/Asset"), "Asset");
+const loadAssetEvents = lazyRoute(() => import("src/pages/Asset/AssetEvents"), 
"AssetEvents");
+const loadAssetStateStore = lazyRoute(() => 
import("src/pages/Asset/AssetStateStore"), "AssetStateStore");
+const loadAssetsList = lazyRoute(() => import("src/pages/AssetsList"), 
"AssetsList");
+const loadConfigs = lazyRoute(() => import("src/pages/Configs"), "Configs");
+const loadConnections = lazyRoute(() => import("src/pages/Connections"), 
"Connections");
+const loadDag = lazyRoute(() => import("src/pages/Dag"), "Dag");
+const loadBackfills = lazyRoute(() => import("src/pages/Dag/Backfills"), 
"Backfills");
+const loadCalendar = lazyRoute(() => 
import("src/pages/Dag/Calendar/Calendar"), "Calendar");
+const loadCode = lazyRoute(() => import("src/pages/Dag/Code"), "Code");
+const loadDagDetails = lazyRoute(() => import("src/pages/Dag/Details"), 
"Details");
+const loadOverview = lazyRoute(() => import("src/pages/Dag/Overview"), 
"Overview");
+const loadTasks = lazyRoute(() => import("src/pages/Dag/Tasks"), "Tasks");
+const loadVersions = lazyRoute(() => import("src/pages/Dag/Versions"), 
"Versions");
+const loadDagBundle = lazyRoute(() => import("src/pages/DagBundle"), 
"DagBundle");
+const loadDagBundles = lazyRoute(() => import("src/pages/DagBundles"), 
"DagBundles");
+const loadDagRuns = lazyRoute(() => import("src/pages/DagRuns"), "DagRuns");
+const loadDagsList = lazyRoute(() => import("src/pages/DagsList"), "DagsList");
+const loadDashboard = lazyRoute(() => import("src/pages/Dashboard"), 
"Dashboard");
+const loadDeadlines = lazyRoute(() => import("src/pages/Deadlines"), 
"Deadlines");
+const loadEvents = lazyRoute(() => import("src/pages/Events"), "Events");
+const loadExternalView = lazyRoute(() => import("src/pages/ExternalView"), 
"ExternalView");
+const loadGroupTaskInstance = lazyRoute(() => 
import("src/pages/GroupTaskInstance"), "GroupTaskInstance");
+const loadGroupTaskInstances = lazyRoute(() => 
import("src/pages/GroupTaskInstance"), "GroupTaskInstances");
+const loadLoopIterations = lazyRoute(() => 
import("src/pages/GroupTaskInstance"), "LoopIterations");
+const loadHITLTaskInstances = lazyRoute(() => 
import("src/pages/HITLTaskInstances"), "HITLTaskInstances");
+const loadJobs = lazyRoute(() => import("src/pages/Jobs"), "Jobs");
+const loadLandingPage = lazyRoute(() => import("src/pages/LandingPage"), 
"LandingPage");
+const loadMappedTaskInstance = lazyRoute(() => 
import("src/pages/MappedTaskInstance"), "MappedTaskInstance");
+const loadMappedTaskInstanceDetails = lazyRoute(
+  () => import("src/pages/MappedTaskInstance/Details"),
+  "Details",
+);
+const loadPlugins = lazyRoute(() => import("src/pages/Plugins"), "Plugins");
+const loadPools = lazyRoute(() => import("src/pages/Pools"), "Pools");
+const loadProviders = lazyRoute(() => import("src/pages/Providers"), 
"Providers");
+const loadRun = lazyRoute(() => import("src/pages/Run"), "Run");
+const loadDagRunAssetEvents = lazyRoute(() => 
import("src/pages/Run/AssetEvents"), "AssetEvents");
+const loadDagRunCallbacks = lazyRoute(() => import("src/pages/Run/Callbacks"), 
"Callbacks");
+const loadDagRunDetails = lazyRoute(() => import("src/pages/Run/Details"), 
"Details");
+const loadSecurity = lazyRoute(() => import("src/pages/Security"), "Security");
+const loadSettings = lazyRoute(() => import("src/pages/Settings"), "Settings");
+const loadTask = lazyRoute(() => import("src/pages/Task"), "Task");
+const loadTaskOverview = lazyRoute(() => import("src/pages/Task/Overview"), 
"Overview");
+const loadTaskInstance = lazyRoute(() => import("src/pages/TaskInstance"), 
"TaskInstance");
+const loadLogs = lazyRoute(() => import("src/pages/TaskInstance"), "Logs");
+const loadTaskInstanceAssetEvents = lazyRoute(
+  () => import("src/pages/TaskInstance/AssetEvents"),
+  "AssetEvents",
+);
+const loadTaskInstanceDefaultTab = lazyRoute(() => 
import("src/pages/TaskInstance/DefaultTab"), "DefaultTab");
+const loadTaskInstanceDetails = lazyRoute(() => 
import("src/pages/TaskInstance/Details"), "Details");
+const loadHITLResponse = lazyRoute(() => 
import("src/pages/TaskInstance/HITLResponse"), "HITLResponse");
+const loadRenderedTemplates = lazyRoute(
+  () => import("src/pages/TaskInstance/RenderedTemplates"),
+  "RenderedTemplates",
+);
+const loadTaskInstances = lazyRoute(() => import("src/pages/TaskInstances"), 
"TaskInstances");
+const loadTaskStateStore = lazyRoute(() => import("src/pages/TaskStateStore"), 
"TaskStateStore");
+const loadVariables = lazyRoute(() => import("src/pages/Variables"), 
"Variables");
+const loadXCom = lazyRoute(() => import("src/pages/XCom"), "XCom");
+
+const loadDagRunsWithLayout = () =>
+  loadRouteModule(async () => {
+    const [{ DagsLayout }, { DagRuns: DagRunsPage }] = await Promise.all([
+      import("src/layouts/DagsLayout"),
+      import("src/pages/DagRuns"),
+    ]);
+
+    return {
+      element: (
+        <DagsLayout>
+          <DagRunsPage />
+        </DagsLayout>
+      ),
+    };
+  });
+
+const loadTaskInstancesWithLayout = () =>
+  loadRouteModule(async () => {
+    const [{ DagsLayout }, { TaskInstances: TaskInstancesPage }] = await 
Promise.all([
+      import("src/layouts/DagsLayout"),
+      import("src/pages/TaskInstances"),
+    ]);
+
+    return {
+      element: (
+        <DagsLayout>
+          <TaskInstancesPage />
+        </DagsLayout>
+      ),
+    };
+  });
+
+const loadHITLTaskInstancesWithDrawer = () =>
+  loadRouteModule(async () => {
+    const { HITLTaskInstances: HITLTaskInstancesPage } = await 
import("src/pages/HITLTaskInstances");
+
+    return { element: <HITLTaskInstancesPage enableHITLReviewDrawer /> };
+  });
+
 const pluginRoute = {
-  element: <ExternalView />,
+  lazy: loadExternalView,
   path: "plugin/:page/*",
 };
 
 export const taskInstanceRoutes = [
-  { element: <TaskInstanceDefaultTab />, index: true, path: undefined },
-  { element: <Logs />, path: TaskInstanceTab.Logs },
-  { element: <Events />, path: TaskInstanceTab.Events },
-  { element: <XCom />, path: TaskInstanceTab.XCom },
-  { element: <TaskStateStore />, path: TaskInstanceTab.TaskStateStore },
-  { element: <Code />, path: TaskInstanceTab.Code },
-  { element: <TaskInstanceDetails />, path: TaskInstanceTab.Details },
-  { element: <RenderedTemplates />, path: TaskInstanceTab.RenderedTemplates },
-  { element: <TaskInstances />, path: TaskInstanceTab.TaskInstances },
-  { element: <TaskInstanceAssetEvents />, path: TaskInstanceTab.AssetEvents },
-  { element: <HITLResponse />, path: TaskInstanceTab.RequiredActions },
+  { index: true, lazy: loadTaskInstanceDefaultTab, path: undefined },
+  { lazy: loadLogs, path: TaskInstanceTab.Logs },
+  { lazy: loadEvents, path: TaskInstanceTab.Events },
+  { lazy: loadXCom, path: TaskInstanceTab.XCom },
+  { lazy: loadTaskStateStore, path: TaskInstanceTab.TaskStateStore },
+  { lazy: loadCode, path: TaskInstanceTab.Code },
+  { lazy: loadTaskInstanceDetails, path: TaskInstanceTab.Details },
+  { lazy: loadRenderedTemplates, path: TaskInstanceTab.RenderedTemplates },
+  { lazy: loadTaskInstances, path: TaskInstanceTab.TaskInstances },
+  { lazy: loadTaskInstanceAssetEvents, path: TaskInstanceTab.AssetEvents },
+  { lazy: loadHITLResponse, path: TaskInstanceTab.RequiredActions },
   pluginRoute,
 ];
 
@@ -104,204 +166,196 @@ export const routerConfig = [
   {
     children: [
       {
-        element: <LandingPage />,
         index: true,
+        lazy: loadLandingPage,
       },
       {
-        element: <Dashboard />,
+        lazy: loadDashboard,
         path: "home",
       },
       {
-        element: <HITLTaskInstances enableHITLReviewDrawer />,
+        lazy: loadHITLTaskInstancesWithDrawer,
         path: "required_actions",
       },
       {
-        element: <DagsList />,
+        lazy: loadDagsList,
         path: "dags",
       },
       {
-        element: (
-          <DagsLayout>
-            <DagRuns />
-          </DagsLayout>
-        ),
+        lazy: loadDagRunsWithLayout,
         path: "dag_runs",
       },
       {
-        element: (
-          <DagsLayout>
-            <TaskInstances />
-          </DagsLayout>
-        ),
+        lazy: loadTaskInstancesWithLayout,
         path: "task_instances",
       },
       {
-        element: <AssetsList />,
+        lazy: loadAssetsList,
         path: "assets",
       },
       {
-        element: <Configs />,
+        lazy: loadConfigs,
         path: "configs",
       },
       {
-        element: <Settings />,
+        lazy: loadSettings,
         path: "settings",
       },
       {
         children: [
-          { element: <AssetEvents />, index: true },
-          { element: <AssetStateStore />, path: "asset-state-store" },
+          { index: true, lazy: loadAssetEvents },
+          { lazy: loadAssetStateStore, path: "asset-state-store" },
           pluginRoute,
         ],
-        element: <Asset />,
+        lazy: loadAsset,
         path: "assets/:assetId",
       },
       {
-        element: <DagBundles />,
+        lazy: loadDagBundles,
         path: "dag_bundles",
       },
       {
-        element: <DagBundle />,
+        lazy: loadDagBundle,
         path: "dag_bundles/:bundleName",
       },
       {
-        element: <Deadlines />,
+        lazy: loadDeadlines,
         path: "deadlines",
       },
       {
-        element: <Events />,
+        lazy: loadEvents,
         path: "events",
       },
       {
-        element: <XCom />,
+        lazy: loadXCom,
         path: "xcoms",
       },
       {
-        element: <Variables />,
+        lazy: loadVariables,
         path: "variables",
       },
       {
-        element: <Pools />,
+        lazy: loadPools,
         path: "pools",
       },
       {
-        element: <Providers />,
+        lazy: loadProviders,
         path: "providers",
       },
       {
-        element: <Plugins />,
+        lazy: loadPlugins,
         path: "plugins",
       },
       {
-        element: <Security />,
+        lazy: loadSecurity,
         path: "security/:page/*",
       },
       {
-        element: <Connections />,
+        lazy: loadConnections,
         path: "connections",
       },
       {
-        element: <Jobs />,
+        lazy: loadJobs,
         path: "jobs",
       },
       pluginRoute,
       {
         children: [
-          { element: <Overview />, handle: { entity: TabEntity.Dag, tab: 
TabName.Overview }, index: true },
-          { element: <DagRuns />, handle: { entity: TabEntity.Dag, tab: 
TabName.Runs }, path: "runs" },
-          { element: <Tasks />, handle: { entity: TabEntity.Dag, tab: 
TabName.Tasks }, path: "tasks" },
+          { handle: { entity: TabEntity.Dag, tab: TabName.Overview }, index: 
true, lazy: loadOverview },
+          { handle: { entity: TabEntity.Dag, tab: TabName.Runs }, lazy: 
loadDagRuns, path: "runs" },
+          { handle: { entity: TabEntity.Dag, tab: TabName.Tasks }, lazy: 
loadTasks, path: "tasks" },
           {
-            element: <Calendar />,
             handle: { entity: TabEntity.Dag, tab: TabName.Calendar },
+            lazy: loadCalendar,
             path: "calendar",
           },
           // The Required Actions tab is now a button + modal; this keeps old 
/required_actions
           // deep links alive by rendering the overview, where the route sync 
opens the modal.
-          { element: <Overview />, path: "required_actions" },
+          { lazy: loadOverview, path: "required_actions" },
           {
-            element: <Backfills />,
             handle: { entity: TabEntity.Dag, tab: TabName.Backfills },
+            lazy: loadBackfills,
             path: "backfills",
           },
-          { element: <Backfills />, path: "backfills/:backfillId" },
-          { element: <Events />, handle: { entity: TabEntity.Dag, tab: 
TabName.Events }, path: "events" },
-          { element: <Code />, handle: { entity: TabEntity.Dag, tab: 
TabName.Code }, path: "code" },
+          { lazy: loadBackfills, path: "backfills/:backfillId" },
+          { handle: { entity: TabEntity.Dag, tab: TabName.Events }, lazy: 
loadEvents, path: "events" },
+          { handle: { entity: TabEntity.Dag, tab: TabName.Code }, lazy: 
loadCode, path: "code" },
           {
-            element: <Versions />,
             handle: { entity: TabEntity.Dag, tab: TabName.Versions },
+            lazy: loadVersions,
             path: "versions",
           },
           {
-            element: <DagDetails />,
             handle: { entity: TabEntity.Dag, tab: TabName.Details },
+            lazy: loadDagDetails,
             path: "details",
           },
           pluginRoute,
         ],
-        element: <Dag />,
+        lazy: loadDag,
         path: "dags/:dagId",
       },
       {
         children: [
-          { element: <TaskInstances />, index: true },
+          { index: true, lazy: loadTaskInstances },
           // The Required Actions tab is now a button + modal; this keeps old 
/required_actions
           // deep links alive by rendering the task instances, where the route 
sync opens the modal.
-          { element: <TaskInstances />, path: "required_actions" },
-          { element: <Events />, path: "events" },
-          { element: <Code />, path: "code" },
-          { element: <DagRunDetails />, path: "details" },
-          { element: <DagRunAssetEvents />, path: "asset_events" },
-          { element: <DagRunCallbacks />, path: "callbacks" },
+          { lazy: loadTaskInstances, path: "required_actions" },
+          { lazy: loadEvents, path: "events" },
+          { lazy: loadCode, path: "code" },
+          { lazy: loadDagRunDetails, path: "details" },
+          { lazy: loadDagRunAssetEvents, path: "asset_events" },
+          { lazy: loadDagRunCallbacks, path: "callbacks" },
           pluginRoute,
         ],
-        element: <Run />,
+        lazy: loadRun,
         path: "dags/:dagId/runs/:runId",
       },
       {
         children: taskInstanceRoutes,
-        element: <TaskInstance />,
+        lazy: loadTaskInstance,
         path: "dags/:dagId/runs/:runId/tasks/:taskId",
       },
       {
         children: [
-          { element: <TaskInstances />, index: true },
-          { element: <MappedTaskInstanceDetails />, path: "details" },
+          { index: true, lazy: loadTaskInstances },
+          { lazy: loadMappedTaskInstanceDetails, path: "details" },
         ],
-        element: <MappedTaskInstance />,
+        lazy: loadMappedTaskInstance,
         path: "dags/:dagId/runs/:runId/tasks/:taskId/mapped",
       },
       {
         children: [
-          { element: <GroupTaskInstances />, index: true },
-          { element: <LoopIterations />, path: "iterations" },
+          { index: true, lazy: loadGroupTaskInstances },
+          { lazy: loadLoopIterations, path: "iterations" },
         ],
-        element: <GroupTaskInstance />,
+        lazy: loadGroupTaskInstance,
         path: "dags/:dagId/runs/:runId/tasks/group/:groupId",
       },
       {
         children: [
-          { element: <TaskOverview />, index: true },
-          { element: <TaskInstances />, path: "task_instances" },
-          { element: <HITLTaskInstances />, path: "required_actions" },
+          { index: true, lazy: loadTaskOverview },
+          { lazy: loadTaskInstances, path: "task_instances" },
+          { lazy: loadHITLTaskInstances, path: "required_actions" },
           pluginRoute,
         ],
-        element: <Task />,
+        lazy: loadTask,
         path: "dags/:dagId/tasks/group/:groupId",
       },
       {
         children: taskInstanceRoutes,
-        element: <TaskInstance />,
+        lazy: loadTaskInstance,
         path: "dags/:dagId/runs/:runId/tasks/:taskId/mapped/:mapIndex",
       },
       {
         children: [
-          { element: <TaskOverview />, index: true },
-          { element: <TaskInstances />, path: "task_instances" },
-          { element: <HITLTaskInstances />, path: "required_actions" },
-          { element: <Events />, path: "events" },
+          { index: true, lazy: loadTaskOverview },
+          { lazy: loadTaskInstances, path: "task_instances" },
+          { lazy: loadHITLTaskInstances, path: "required_actions" },
+          { lazy: loadEvents, path: "events" },
           pluginRoute,
         ],
-        element: <Task />,
+        lazy: loadTask,
         path: "dags/:dagId/tasks/:taskId",
       },
       {
diff --git a/airflow-core/src/airflow/ui/src/utils/AppWrapper.tsx 
b/airflow-core/src/airflow/ui/src/utils/AppWrapper.tsx
index 6e1799e8cf3..e637feba5f6 100644
--- a/airflow-core/src/airflow/ui/src/utils/AppWrapper.tsx
+++ b/airflow-core/src/airflow/ui/src/utils/AppWrapper.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { RouterProvider, createMemoryRouter } from "react-router-dom";
+import { RouterProvider, createMemoryRouter, matchRoutes, type RouteObject } 
from "react-router-dom";
 
 import { TimezoneProvider } from "src/context/timezone";
 import { routerConfig } from "src/router";
@@ -26,6 +26,20 @@ type Props = {
   readonly initialEntries: Array<string>;
 };
 
+export const preloadAppRoutes = async (initialEntries: Array<string>) => {
+  const routeLoaders = new Set<() => Promise<unknown>>();
+
+  for (const entry of initialEntries) {
+    for (const { route } of matchRoutes(routerConfig as Array<RouteObject>, 
entry) ?? []) {
+      if (typeof route.lazy === "function") {
+        routeLoaders.add(route.lazy);
+      }
+    }
+  }
+
+  await Promise.all([...routeLoaders].map((loadRoute) => loadRoute()));
+};
+
 export const AppWrapper = ({ initialEntries }: Props) => {
   // Strip the config loader from tests
   const config = routerConfig.map((route) => ({
diff --git a/airflow-core/src/airflow/ui/src/utils/routeModule.ts 
b/airflow-core/src/airflow/ui/src/utils/routeModule.ts
new file mode 100644
index 00000000000..4d217e1d636
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/routeModule.ts
@@ -0,0 +1,45 @@
+/*!
+ * 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.
+ */
+
+const ROUTE_MODULE_RELOAD_KEY = "airflow-route-module-reload";
+const ROUTE_MODULE_RELOAD_ATTEMPTED = "attempted";
+const ROUTE_MODULE_RELOAD_PENDING = "pending";
+
+export const loadRouteModule = async <Module>(factory: () => Promise<Module>) 
=> {
+  try {
+    const module = await factory();
+
+    globalThis.sessionStorage.removeItem(ROUTE_MODULE_RELOAD_KEY);
+
+    return module;
+  } catch (error) {
+    if (globalThis.sessionStorage.getItem(ROUTE_MODULE_RELOAD_KEY) !== 
ROUTE_MODULE_RELOAD_ATTEMPTED) {
+      globalThis.sessionStorage.setItem(ROUTE_MODULE_RELOAD_KEY, 
ROUTE_MODULE_RELOAD_PENDING);
+    }
+
+    throw error;
+  }
+};
+
+export const reloadAfterFailedRouteModuleLoad = () => {
+  if (globalThis.sessionStorage.getItem(ROUTE_MODULE_RELOAD_KEY) === 
ROUTE_MODULE_RELOAD_PENDING) {
+    globalThis.sessionStorage.setItem(ROUTE_MODULE_RELOAD_KEY, 
ROUTE_MODULE_RELOAD_ATTEMPTED);
+    globalThis.location.reload();
+  }
+};

Reply via email to