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 8ce7c84fa7b UI: Pass Dag, DagRun, TaskInstance, and Asset objects to 
React app plugins (#70149)
8ce7c84fa7b is described below

commit 8ce7c84fa7bfccdfec07ec60c89bc9ba0b75278b
Author: Dheeraj Turaga <[email protected]>
AuthorDate: Wed Jul 22 11:27:43 2026 -0500

    UI: Pass Dag, DagRun, TaskInstance, and Asset objects to React app plugins 
(#70149)
    
    Follows the pattern established in #69986, which enriched the props passed
    to React app plugins beyond bare route params by resolving assetUri from the
    UI's TanStack Query cache. This extends that pattern to the full context
    objects: React plugins now receive dag, dagRun, taskInstance, and asset as
    typed props alongside the existing route-id strings.
    
    Each object is resolved from the query cache and gated on the route params
    it depends on, so it is served from the cache the parent details page
    already populated (no extra request) where present, and is undefined on
    routes or destinations that lack those ids (e.g. nav, base, dashboard).
---
 .../docs/administration-and-deployment/plugins.rst |  21 +++
 .../src/airflow/ui/src/pages/ReactPlugin.test.tsx  | 144 +++++++++++++++++++++
 .../src/airflow/ui/src/pages/ReactPlugin.tsx       |  78 +++++++----
 3 files changed, 221 insertions(+), 22 deletions(-)

diff --git a/airflow-core/docs/administration-and-deployment/plugins.rst 
b/airflow-core/docs/administration-and-deployment/plugins.rst
index 2766da35bb1..31c6f16fd0e 100644
--- a/airflow-core/docs/administration-and-deployment/plugins.rst
+++ b/airflow-core/docs/administration-and-deployment/plugins.rst
@@ -301,6 +301,27 @@ definitions in Airflow.
 
 .. seealso:: :doc:`/howto/define-extra-link`
 
+React app context props
+-----------------------
+
+.. note::
+    The React app integration is experimental and these props may change in 
future versions.
+
+Unlike an external view, which only receives context through 
``{DAG_ID}``-style tokens in its
+``bundle_url``, a React app is rendered as a component and receives context 
directly as props.
+The props available depend on where the app is mounted (its ``destination`` 
and route):
+
+- ``dagId``, ``runId``, ``taskId``, ``mapIndex``, ``assetId`` — the 
identifiers from the current
+  route (strings), when present.
+- ``assetUri`` — the URI of the current asset, when mounted on an asset route.
+- ``dag``, ``dagRun``, ``taskInstance``, ``asset`` — the full records for the 
current route,
+  matching the corresponding REST API response schemas
+  (``DAGDetailsResponse``, ``DAGRunResponse``, ``TaskInstanceResponse``, 
``AssetResponse``).
+  Each object is only provided once the identifiers it depends on are present 
in the route, and
+  is served from the UI's query cache the details page has already populated 
(no extra request).
+  On routes or ``destination`` values without those identifiers (e.g. ``nav``, 
``base``,
+  ``dashboard``), the corresponding objects are ``undefined``.
+
 Exclude views from CSRF protection
 ----------------------------------
 
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..7f0fb6d5609
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
@@ -0,0 +1,144 @@
+/*!
+ * 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";
+import { render } from "@testing-library/react";
+import type * as ReactRouterDom from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type {
+  AssetResponse,
+  DAGDetailsResponse,
+  DAGRunResponse,
+  ReactAppResponse,
+  TaskInstanceResponse,
+} from "openapi/requests/types.gen";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { ReactPlugin, type PluginProps } from "./ReactPlugin";
+
+const mockAsset = { id: 1, name: "my_asset", uri: "s3://bucket/key" } as 
AssetResponse;
+const mockDag = { dag_display_name: "My Dag", dag_id: "my_dag" } as 
DAGDetailsResponse;
+const mockDagRun = { dag_id: "my_dag", dag_run_id: "run_1", state: "success" } 
as DAGRunResponse;
+const mockTaskInstance = { dag_id: "my_dag", task_id: "my_task" } as 
TaskInstanceResponse;
+
+let mockParams: Record<string, string> = {};
+
+vi.mock("react-router-dom", async (importOriginal) => ({
+  ...(await importOriginal<typeof ReactRouterDom>()),
+  useParams: () => mockParams,
+}));
+
+type QueryOptions = { enabled?: boolean };
+
+// Each mocked hook mirrors the real gating: it returns its record only when 
the caller enables
+// the query (i.e. the route params it depends on are present), and undefined 
otherwise.
+vi.mock("openapi/queries", () => ({
+  useAssetServiceGetAsset: (_params: unknown, _key: unknown, options?: 
QueryOptions) => ({
+    data: options?.enabled ? mockAsset : undefined,
+  }),
+  useDagRunServiceGetDagRun: (_params: unknown, _key: unknown, options?: 
QueryOptions) => ({
+    data: options?.enabled ? mockDagRun : undefined,
+  }),
+  useDagServiceGetDagDetails: (_params: unknown, _key: unknown, options?: 
QueryOptions) => ({
+    data: options?.enabled ? mockDag : undefined,
+  }),
+  useTaskInstanceServiceGetMappedTaskInstance: (_params: unknown, _key: 
unknown, options?: QueryOptions) => ({
+    data: options?.enabled ? mockTaskInstance : undefined,
+  }),
+}));
+
+const reactApp = {
+  bundle_url: "http://localhost/plugin.js";,
+  destination: "dag",
+  name: "TestPlugin",
+} as ReactAppResponse;
+
+let capturedProps: PluginProps | undefined;
+
+const renderPlugin = () => {
+  capturedProps = undefined;
+  // Pre-register the component so ReactPlugin renders it directly instead of 
lazy-loading a bundle.
+  (globalThis as Record<string, unknown>)[reactApp.name] = (props: 
PluginProps) => {
+    capturedProps = props;
+
+    return null;
+  };
+
+  render(<ReactPlugin reactApp={reactApp} />, { wrapper: Wrapper });
+};
+
+describe("ReactPlugin context props", () => {
+  beforeEach(() => {
+    mockParams = {};
+  });
+
+  afterEach(() => {
+    (globalThis as Record<string, unknown>)[reactApp.name] = undefined;
+  });
+
+  it("passes no context objects when the route has no params (base/dashboard 
mounts)", () => {
+    renderPlugin();
+
+    expect(capturedProps?.dag).toBeUndefined();
+    expect(capturedProps?.dagRun).toBeUndefined();
+    expect(capturedProps?.taskInstance).toBeUndefined();
+    expect(capturedProps?.asset).toBeUndefined();
+  });
+
+  it("passes the cached Dag object when dagId is in the route", () => {
+    mockParams = { dagId: "my_dag" };
+
+    renderPlugin();
+
+    expect(capturedProps?.dagId).toBe("my_dag");
+    expect(capturedProps?.dag).toStrictEqual(mockDag);
+    expect(capturedProps?.dagRun).toBeUndefined();
+    expect(capturedProps?.taskInstance).toBeUndefined();
+  });
+
+  it("passes the cached DagRun object when dagId and runId are in the route", 
() => {
+    mockParams = { dagId: "my_dag", runId: "run_1" };
+
+    renderPlugin();
+
+    expect(capturedProps?.dag).toStrictEqual(mockDag);
+    expect(capturedProps?.dagRun).toStrictEqual(mockDagRun);
+    expect(capturedProps?.taskInstance).toBeUndefined();
+  });
+
+  it("passes the cached TaskInstance object when dagId, runId and taskId are 
in the route", () => {
+    mockParams = { dagId: "my_dag", runId: "run_1", taskId: "my_task" };
+
+    renderPlugin();
+
+    expect(capturedProps?.dag).toStrictEqual(mockDag);
+    expect(capturedProps?.dagRun).toStrictEqual(mockDagRun);
+    expect(capturedProps?.taskInstance).toStrictEqual(mockTaskInstance);
+  });
+
+  it("passes the cached Asset object and its uri when assetId is in the 
route", () => {
+    mockParams = { assetId: "1" };
+
+    renderPlugin();
+
+    expect(capturedProps?.assetId).toBe("1");
+    expect(capturedProps?.asset).toStrictEqual(mockAsset);
+    expect(capturedProps?.assetUri).toBe(mockAsset.uri);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx 
b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
index 402dddeecea..a14fdcb13d3 100644
--- a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
@@ -20,18 +20,33 @@ import { Spinner } from "@chakra-ui/react";
 import { type FC, lazy, Suspense } from "react";
 import { useParams } from "react-router-dom";
 
-import { useAssetServiceGetAsset } from "openapi/queries";
-import type { ReactAppResponse } from "openapi/requests/types.gen";
+import {
+  useAssetServiceGetAsset,
+  useDagRunServiceGetDagRun,
+  useDagServiceGetDagDetails,
+  useTaskInstanceServiceGetMappedTaskInstance,
+} from "openapi/queries";
+import type {
+  AssetResponse,
+  DAGDetailsResponse,
+  DAGRunResponse,
+  ReactAppResponse,
+  TaskInstanceResponse,
+} from "openapi/requests/types.gen";
 
 import { ErrorPage } from "./Error";
 
 export type PluginProps = {
+  asset?: AssetResponse;
   assetId?: string;
   assetUri?: string;
+  dag?: DAGDetailsResponse;
   dagId?: string;
+  dagRun?: DAGRunResponse;
   mapIndex?: string;
   runId?: string;
   taskId?: string;
+  taskInstance?: TaskInstanceResponse;
 };
 
 type PluginComponentType = FC<PluginProps>;
@@ -67,8 +82,9 @@ const loadPlugin = (reactApp: ReactAppResponse): Promise<{ 
default: PluginCompon
 export const ReactPlugin = ({ reactApp }: { readonly reactApp: 
ReactAppResponse }) => {
   const { assetId, dagId, mapIndex, runId, taskId } = useParams();
 
-  // The asset URI is not part of the route, so resolve it from the asset 
record. This is a
-  // cache hit because the asset details page has already fetched it.
+  // Context objects are not part of the route, so resolve them from the query 
cache. Each query
+  // is gated on the route params it needs, so it stays disabled where those 
params are absent
+  // (e.g. base/dashboard mounts) and is a cache hit where the parent details 
page already fetched.
   const { data: asset } = useAssetServiceGetAsset(
     { assetId: assetId === undefined ? 0 : parseInt(assetId, 10) },
     undefined,
@@ -76,6 +92,40 @@ export const ReactPlugin = ({ reactApp }: { readonly 
reactApp: ReactAppResponse
   );
   const assetUri = asset?.uri;
 
+  const { data: dag } = useDagServiceGetDagDetails({ dagId: dagId ?? "" }, 
undefined, {
+    enabled: Boolean(dagId),
+  });
+
+  const { data: dagRun } = useDagRunServiceGetDagRun(
+    { dagId: dagId ?? "", dagRunId: runId ?? "" },
+    undefined,
+    { enabled: Boolean(dagId) && Boolean(runId) },
+  );
+
+  const { data: taskInstance } = useTaskInstanceServiceGetMappedTaskInstance(
+    {
+      dagId: dagId ?? "",
+      dagRunId: runId ?? "",
+      mapIndex: mapIndex === undefined ? -1 : parseInt(mapIndex, 10),
+      taskId: taskId ?? "",
+    },
+    undefined,
+    { enabled: Boolean(dagId) && Boolean(runId) && Boolean(taskId) },
+  );
+
+  const pluginProps: PluginProps = {
+    asset,
+    assetId,
+    assetUri,
+    dag,
+    dagId,
+    dagRun,
+    mapIndex,
+    runId,
+    taskId,
+    taskInstance,
+  };
+
   // If the plugin component was already registered on the global object by a 
previous load,
   // render it directly without going through Suspense/lazy (avoids flashing 
the spinner).
   const existing = (globalThis as Record<string, unknown>)[reactApp.name];
@@ -83,16 +133,7 @@ export const ReactPlugin = ({ reactApp }: { readonly 
reactApp: ReactAppResponse
   if (typeof existing === "function") {
     const Plugin = existing as PluginComponentType;
 
-    return (
-      <Plugin
-        assetId={assetId}
-        assetUri={assetUri}
-        dagId={dagId}
-        mapIndex={mapIndex}
-        runId={runId}
-        taskId={taskId}
-      />
-    );
+    return <Plugin {...pluginProps} />;
   }
 
   // Otherwise, lazy-load the bundle once. When it resolves, it must set a 
function component
@@ -101,14 +142,7 @@ export const ReactPlugin = ({ reactApp }: { readonly 
reactApp: ReactAppResponse
 
   return (
     <Suspense fallback={<Spinner />}>
-      <LazyPlugin
-        assetId={assetId}
-        assetUri={assetUri}
-        dagId={dagId}
-        mapIndex={mapIndex}
-        runId={runId}
-        taskId={taskId}
-      />
+      <LazyPlugin {...pluginProps} />
     </Suspense>
   );
 };

Reply via email to