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

Reply via email to