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