This is an automated email from the ASF dual-hosted git repository.
shahar1 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new 76839662c3e [v3-4-test] UI: Defer React plugin compatibility runtime
(#75083) (#76548)
76839662c3e is described below
commit 76839662c3e070a9ddb1a9dd151db82d982e0c42
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Nov 6 08:01:42 2026 +0000
[v3-4-test] UI: Defer React plugin compatibility runtime (#75083) (#76548)
* [v3-4-test] UI: Defer React plugin compatibility runtime (#75083)
* UI: Defer React plugin compatibility runtime
React plugin compatibility currently keeps complete framework namespaces in
the initial UI payload even when no React plugins are installed.
* UI: Refresh stale React plugin bundles
(cherry picked from commit b5ab0a1517452e8d2d346e7fe8dbf03052811ed7)
Co-authored-by: safaehar <[email protected]>
* UI: Add routeModule helper missing from v3-4-test
The backported React plugin compatibility runtime change imports
src/utils/routeModule.ts, which was introduced on main by #74663 and was never
backported to v3-4-test, so the UI build fails without it.
Claude-Session: https://claude.ai/code/session_01BVx6g9o781XF2cUJzBzr6U
---------
Co-authored-by: safaehar <[email protected]>
Co-authored-by: Shahar Epstein <[email protected]>
---
airflow-core/src/airflow/ui/src/main.tsx | 19 +------
.../src/airflow/ui/src/pages/ReactPlugin.test.tsx | 25 ++++++++-
.../src/airflow/ui/src/pages/ReactPlugin.tsx | 14 ++++-
.../src/utils/registerPluginDependencies.test.ts | 59 ++++++++++++++++++++++
.../ui/src/utils/registerPluginDependencies.ts | 36 +++++++++++++
.../src/airflow/ui/src/utils/routeModule.ts | 45 +++++++++++++++++
6 files changed, 178 insertions(+), 20 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/main.tsx
b/airflow-core/src/airflow/ui/src/main.tsx
index 798d5cb6139..bbf3699759d 100644
--- a/airflow-core/src/airflow/ui/src/main.tsx
+++ b/airflow-core/src/airflow/ui/src/main.tsx
@@ -16,21 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-// Plugins are UMD-bundled against a `React` global, so this republishes the
-// host's React to them verbatim — the one place the namespace import is
needed.
-// eslint-disable-next-line @typescript-eslint/no-restricted-imports
-import React, { StrictMode } from "react";
+import { StrictMode } from "react";
-import * as ChakraUI from "@chakra-ui/react";
-import * as EmotionReact from "@emotion/react";
import { QueryClientProvider } from "@tanstack/react-query";
import axios, { type AxiosError } from "axios";
-import * as ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import { RouterProvider } from "react-router-dom";
-import * as ReactRouterDOM from "react-router-dom";
-import * as ReactJSXRuntime from "react/jsx-runtime";
import type { HTTPExceptionResponse } from "openapi/requests/types.gen";
@@ -45,15 +37,6 @@ import { getNextHref, getRedirectPath } from
"src/utils/links.ts";
import i18n from "./i18n/config";
import { client } from "./queryClient";
-// Set React, ReactDOM, Chakra UI, and Emotion on globalThis so dynamically
imported React
-// plugins (e.g. HITL Review) use the host's copies instead of bundling their
own.
-Reflect.set(globalThis, "React", React);
-Reflect.set(globalThis, "ReactDOM", ReactDOM);
-Reflect.set(globalThis, "ReactJSXRuntime", ReactJSXRuntime);
-Reflect.set(globalThis, "ReactRouterDOM", ReactRouterDOM);
-Reflect.set(globalThis, "ChakraUI", ChakraUI);
-Reflect.set(globalThis, "EmotionReact", EmotionReact);
-
// URLs that returned 403 Forbidden. Permissions won't change mid-session,
// so we block further requests to avoid spamming the server with polling.
const forbidden403Urls = new Set<string>();
diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
index ab12e73ce37..3a882b4bf9a 100644
--- a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
@@ -177,12 +177,14 @@ describe("loadPlugin", () => {
}
});
- it("renders nothing when the bundle cannot be fetched", async () => {
+ it("reloads once and renders nothing when the bundle cannot be fetched",
async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const reload = vi.spyOn(globalThis.location,
"reload").mockImplementation(() => undefined);
const { default: Component } = await loadPlugin(pluginA, () =>
Promise.reject(new Error("network down")));
const { container } = render(<Component />, { wrapper: Wrapper });
+ expect(reload).toHaveBeenCalledOnce();
// An error page here would bury the page the plugin was only decorating.
expect(container).toBeEmptyDOMElement();
});
@@ -198,6 +200,27 @@ describe("loadPlugin", () => {
expect(component).not.toBe(componentA);
});
+
+ it("registers plugin dependencies before importing the plugin bundle", async
() => {
+ const calls: Array<string> = [];
+
+ await loadPlugin(
+ pluginA,
+ () => {
+ calls.push("bundle");
+ (globalThis as Record<string, unknown>).AirflowPlugin = componentA;
+
+ return Promise.resolve();
+ },
+ () => {
+ calls.push("dependencies");
+
+ return Promise.resolve();
+ },
+ );
+
+ expect(calls).toEqual(["dependencies", "bundle"]);
+ });
});
const loading = (destination: ReactAppResponse["destination"]) =>
diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
index cf13bb3d250..5299afdaf51 100644
--- a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
@@ -36,6 +36,7 @@ import type {
} from "openapi/requests/types.gen";
import { useTaskInstanceCoordinates } from
"src/hooks/useTaskInstanceCoordinates";
+import { loadRouteModule, reloadAfterFailedRouteModuleLoad } from
"src/utils/routeModule";
export type PluginProps = {
asset?: AssetResponse;
@@ -52,14 +53,23 @@ export type PluginProps = {
type PluginComponentType = FC<PluginProps>;
+const loadPluginDependencies = async () => {
+ const { registerPluginDependencies } = await
import("src/utils/registerPluginDependencies");
+
+ registerPluginDependencies();
+};
+
export const loadPlugin = (
reactApp: ReactAppResponse,
importBundle: (url: string) => Promise<unknown> = (url) => import(/*
@vite-ignore */ url),
+ loadDependencies: () => Promise<void> = loadPluginDependencies,
): Promise<{ default: PluginComponentType }> => {
(globalThis as Record<string, unknown>).AirflowPlugin = undefined;
// We are assuming the plugin manager is trusted and the bundle_url is safe
- return importBundle(new URL(reactApp.bundle_url, document.baseURI).href)
+ return loadRouteModule(() =>
+ loadDependencies().then(() => importBundle(new URL(reactApp.bundle_url,
document.baseURI).href)),
+ )
.then(() => {
// Store components in globalThis[reactApp.name] to avoid conflicts with
the shared globalThis.AirflowPlugin
// global variable.
@@ -79,6 +89,8 @@ export const loadPlugin = (
return { default: pluginComponent };
})
.catch((error: unknown) => {
+ reloadAfterFailedRouteModuleLoad();
+
// eslint-disable-next-line no-console
console.error("Component failed to load:", error);
diff --git
a/airflow-core/src/airflow/ui/src/utils/registerPluginDependencies.test.ts
b/airflow-core/src/airflow/ui/src/utils/registerPluginDependencies.test.ts
new file mode 100644
index 00000000000..69dc343973a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/registerPluginDependencies.test.ts
@@ -0,0 +1,59 @@
+/*!
+ * 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.
+ */
+// Plugin UMD bundles expect the host's React object as a global.
+// eslint-disable-next-line @typescript-eslint/no-restricted-imports
+import React from "react";
+
+import * as ChakraUI from "@chakra-ui/react";
+import * as EmotionReact from "@emotion/react";
+import * as ReactDOM from "react-dom";
+import * as ReactRouterDOM from "react-router-dom";
+import * as ReactJSXRuntime from "react/jsx-runtime";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { registerPluginDependencies } from "./registerPluginDependencies";
+
+const dependencyGlobals = [
+ "React",
+ "ReactDOM",
+ "ReactJSXRuntime",
+ "ReactRouterDOM",
+ "ChakraUI",
+ "EmotionReact",
+] as const;
+const originalGlobals = new Map(dependencyGlobals.map((key) => [key,
Reflect.get(globalThis, key)]));
+
+afterEach(() => {
+ for (const key of dependencyGlobals) {
+ Reflect.set(globalThis, key, originalGlobals.get(key));
+ }
+});
+
+describe("registerPluginDependencies", () => {
+ it("publishes the host dependencies expected by plugin bundles", () => {
+ registerPluginDependencies();
+
+ expect(Reflect.get(globalThis, "React")).toBe(React);
+ expect(Reflect.get(globalThis, "ReactDOM")).toBe(ReactDOM);
+ expect(Reflect.get(globalThis, "ReactJSXRuntime")).toBe(ReactJSXRuntime);
+ expect(Reflect.get(globalThis, "ReactRouterDOM")).toBe(ReactRouterDOM);
+ expect(Reflect.get(globalThis, "ChakraUI")).toBe(ChakraUI);
+ expect(Reflect.get(globalThis, "EmotionReact")).toBe(EmotionReact);
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/utils/registerPluginDependencies.ts
b/airflow-core/src/airflow/ui/src/utils/registerPluginDependencies.ts
new file mode 100644
index 00000000000..6b75ee41b5d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/registerPluginDependencies.ts
@@ -0,0 +1,36 @@
+/*!
+ * 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.
+ */
+// Plugin UMD bundles expect the host's React object as a global.
+// eslint-disable-next-line @typescript-eslint/no-restricted-imports
+import React from "react";
+
+import * as ChakraUI from "@chakra-ui/react";
+import * as EmotionReact from "@emotion/react";
+import * as ReactDOM from "react-dom";
+import * as ReactRouterDOM from "react-router-dom";
+import * as ReactJSXRuntime from "react/jsx-runtime";
+
+export const registerPluginDependencies = () => {
+ Reflect.set(globalThis, "React", React);
+ Reflect.set(globalThis, "ReactDOM", ReactDOM);
+ Reflect.set(globalThis, "ReactJSXRuntime", ReactJSXRuntime);
+ Reflect.set(globalThis, "ReactRouterDOM", ReactRouterDOM);
+ Reflect.set(globalThis, "ChakraUI", ChakraUI);
+ Reflect.set(globalThis, "EmotionReact", EmotionReact);
+};
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();
+ }
+};