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

pierrejeambrun pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 8d774f59467 Prevent React plugins from inheriting a prior bundle's 
component (#71943) (#72136)
8d774f59467 is described below

commit 8d774f59467f27070b9ea706ddf15556202701c0
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Thu Aug 27 12:05:21 2026 +0200

    Prevent React plugins from inheriting a prior bundle's component (#71943) 
(#72136)
    
    A React plugin bundle exports its component by assigning to the shared
    globalThis.AirflowPlugin as a side effect of being imported. When a bundle 
is
    malformed or empty and never makes that assignment, the loader read whatever
    value a previously-loaded plugin had left there, so one plugin silently
    rendered another plugin's component instead of failing. Clear the shared 
global
    before each import so a bundle that assigns nothing surfaces a clear error
    rather than inheriting a stale component.
    
    (cherry picked from commit bdf3ee57032da6934d27ff8302114b730d849c56)
---
 .../src/airflow/ui/src/pages/ReactPlugin.test.tsx  | 47 ++++++++++++++++++++++
 .../src/airflow/ui/src/pages/ReactPlugin.tsx       | 10 ++++-
 2 files changed, 55 insertions(+), 2 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
new file mode 100644
index 00000000000..3a8e07990c9
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
@@ -0,0 +1,47 @@
+/*!
+ * 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 { afterEach, describe, expect, it } from "vitest";
+
+import type { ReactAppResponse } from "openapi/requests/types.gen";
+
+import { loadPlugin } from "./ReactPlugin";
+
+const pluginA = { bundle_url: "http://localhost/a.js";, name: "PluginA" } as 
ReactAppResponse;
+const pluginB = { bundle_url: "http://localhost/b.js";, name: "PluginB" } as 
ReactAppResponse;
+const componentA = () => null;
+
+describe("loadPlugin", () => {
+  afterEach(() => {
+    for (const key of ["AirflowPlugin", pluginA.name, pluginB.name]) {
+      (globalThis as Record<string, unknown>)[key] = undefined;
+    }
+  });
+
+  it("does not let a malformed bundle inherit a previously-loaded plugin's 
component", async () => {
+    // A well-formed bundle sets globalThis.AirflowPlugin on import; a 
malformed one sets nothing.
+    await loadPlugin(pluginA, () => {
+      (globalThis as Record<string, unknown>).AirflowPlugin = componentA;
+
+      return Promise.resolve();
+    });
+    const { default: component } = await loadPlugin(pluginB, () => 
Promise.resolve());
+
+    expect(component).not.toBe(componentA);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx 
b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
index d78198be9ea..0625ea4a8af 100644
--- a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
@@ -33,9 +33,14 @@ export type PluginProps = {
 
 type PluginComponentType = FC<PluginProps>;
 
-const loadPlugin = (reactApp: ReactAppResponse): Promise<{ default: 
PluginComponentType }> =>
+export const loadPlugin = (
+  reactApp: ReactAppResponse,
+  importBundle: (url: string) => Promise<unknown> = (url) => import(/* 
@vite-ignore */ url),
+): Promise<{ default: PluginComponentType }> => {
+  (globalThis as Record<string, unknown>).AirflowPlugin = undefined;
+
   // We are assuming the plugin manager is trusted and the bundle_url is safe
-  import(/* @vite-ignore */ new URL(reactApp.bundle_url, 
document.baseURI).href)
+  return 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.
@@ -60,6 +65,7 @@ const loadPlugin = (reactApp: ReactAppResponse): Promise<{ 
default: PluginCompon
 
       return { default: ErrorPage };
     });
+};
 
 export const ReactPlugin = ({ reactApp }: { readonly reactApp: 
ReactAppResponse }) => {
   const { dagId, mapIndex, runId, taskId } = useParams();

Reply via email to