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