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

bbovenzi 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 6bc9a6a5ebd [v3-4-test] UI: Preload base React plugin bundles with the 
page (#74777) (#75259)
6bc9a6a5ebd is described below

commit 6bc9a6a5ebdaab2900bc75c6c72174ce91d95206
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Oct 21 09:10:46 2026 -0400

    [v3-4-test] UI: Preload base React plugin bundles with the page (#74777) 
(#75259)
    
    * UI: Preload base React plugin bundles with the page
    
    A React plugin with destination "base" renders on every page, but the
    UI only learns its bundle URL from the plugins endpoint after its own
    scripts have run. The bundle download starts late, and the page moves
    down when the plugin appears.
    
    The server now adds a modulepreload link for each base app's bundle to
    index.html, so the browser fetches it alongside the UI's scripts. The
    UI's later import() of the same URL reuses the preloaded module. Only
    plugins without a team are included, because the page is rendered
    before the user is known.
    
    * Cover the empty and escaped cases of the preload links
    (cherry picked from commit 165f5ad90545baab74c24be4ae2545dfb2736018)
    
    Co-authored-by: Julian LaNeve <[email protected]>
---
 .../docs/administration-and-deployment/plugins.rst |  1 +
 .../src/airflow/api_fastapi/core_api/app.py        | 24 +++++++-
 airflow-core/src/airflow/ui/dev/index.html         |  3 +
 airflow-core/src/airflow/ui/index.html             |  3 +
 .../tests/unit/api_fastapi/core_api/test_app.py    | 68 +++++++++++++++++++++-
 5 files changed, 96 insertions(+), 3 deletions(-)

diff --git a/airflow-core/docs/administration-and-deployment/plugins.rst 
b/airflow-core/docs/administration-and-deployment/plugins.rst
index b9965b54349..0619f08bb9b 100644
--- a/airflow-core/docs/administration-and-deployment/plugins.rst
+++ b/airflow-core/docs/administration-and-deployment/plugins.rst
@@ -360,6 +360,7 @@ definitions in Airflow.
         # It can also be put inside of an existing page, the supported views 
are ["dashboard", "dag_overview", "task_overview"]. You can position
         # element in the existing page via the css `order` rule which will 
determine the flex order.
         # Use "base" to mount the app in the base layout (e.g. a toolbar 
strip); the host uses a flex container so you can set ``order`` in your root 
JSX to control position.
+        # The server preloads the bundle of a "base" app from a plugin with no 
team, so it downloads alongside the UI's own scripts.
         "destination": "task",
         # Optional icon, url to an svg file.
         "icon": "https://example.com/icon.svg";,
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/app.py 
b/airflow-core/src/airflow/api_fastapi/core_api/app.py
index 841c46c2291..c4612ed8987 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py
@@ -139,6 +139,24 @@ def init_ui_translation_views(app: FastAPI, *, dev_mode: 
bool, dist_directory: P
             )
 
 
+def base_react_app_bundle_urls() -> list[str]:
+    """
+    Bundle URLs of the React apps that render on every page, for the server to 
preload.
+
+    Only plugins without a team are included: the page is rendered before the 
user is known, and
+    the plugins endpoint hides a team-scoped plugin from users outside its 
team.
+    """
+    from airflow import plugins_manager
+
+    return [
+        react_app["bundle_url"]
+        for plugin in 
plugins_manager.get_plugin_info(attrs_to_dump=["react_apps"])
+        if plugin["team_name"] is None
+        for react_app in plugin["react_apps"]
+        if react_app.get("destination") == "base" and 
isinstance(react_app.get("bundle_url"), str)
+    ]
+
+
 def init_views(app: FastAPI) -> None:
     """Init views by registering the different routers."""
     from airflow.api_fastapi.core_api.routes.public import public_router
@@ -156,6 +174,7 @@ def init_views(app: FastAPI) -> None:
     Path(directory).mkdir(exist_ok=True)
 
     templates = Jinja2Templates(directory=directory)
+    preload_bundle_urls = base_react_app_bundle_urls()
 
     # Ahead of the static mounts below so plugin-overridden locales take 
precedence.
     init_ui_translation_views(app, dev_mode=dev_mode, dist_directory=directory)
@@ -207,7 +226,10 @@ def init_views(app: FastAPI) -> None:
         return templates.TemplateResponse(
             request,
             "/index.html",
-            {"backend_server_base_url": request.base_url.path},
+            {
+                "backend_server_base_url": request.base_url.path,
+                "preload_bundle_urls": preload_bundle_urls,
+            },
             media_type="text/html",
         )
 
diff --git a/airflow-core/src/airflow/ui/dev/index.html 
b/airflow-core/src/airflow/ui/dev/index.html
index 10afd373f8f..2eda7ac0e5e 100644
--- a/airflow-core/src/airflow/ui/dev/index.html
+++ b/airflow-core/src/airflow/ui/dev/index.html
@@ -4,6 +4,9 @@
   <head>
     <meta charset="UTF-8" />
     <base href="{{ backend_server_base_url }}" />
+    {% for bundle_url in preload_bundle_urls %}
+    <link rel="modulepreload" href="{{ bundle_url }}" />
+    {% endfor %}
     <link rel="icon" type="image/png" href="http://localhost:5173/pin_32.png"; 
/>
     <script type="module" src="http://localhost:5173/@vite/client";></script>
     <script type="module">
diff --git a/airflow-core/src/airflow/ui/index.html 
b/airflow-core/src/airflow/ui/index.html
index 952a8d3becb..1553a4fb942 100644
--- a/airflow-core/src/airflow/ui/index.html
+++ b/airflow-core/src/airflow/ui/index.html
@@ -3,6 +3,9 @@
   <head>
     <meta charset="UTF-8" />
     <base href="{{ backend_server_base_url }}" />
+    {% for bundle_url in preload_bundle_urls %}
+    <link rel="modulepreload" href="{{ bundle_url }}" />
+    {% endfor %}
     <link rel="icon" type="image/png" href="/static/pin_32.png" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>Airflow</title>
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py 
b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
index 43f28bcba5f..3d26d632639 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
@@ -22,15 +22,20 @@ import json
 import typing
 
 import pytest
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.params import Depends as DependsClass
 from fastapi.responses import StreamingResponse
+from fastapi.templating import Jinja2Templates
 from starlette.routing import Mount
 from starlette.testclient import TestClient
 
 from airflow.api_fastapi.app import create_app
-from airflow.api_fastapi.core_api.app import init_config, 
init_ui_translation_views
+from airflow.api_fastapi.core_api.app import (
+    base_react_app_bundle_urls,
+    init_config,
+    init_ui_translation_views,
+)
 from airflow.api_fastapi.core_api.routes.public import authenticated_router
 from airflow.api_fastapi.core_api.routes.ui import ui_router
 from airflow.api_fastapi.core_api.security import get_user
@@ -39,6 +44,7 @@ from airflow.plugins_manager import AirflowPlugin
 from tests_common.test_utils.config import conf_vars
 from tests_common.test_utils.db import clear_db_jobs
 from tests_common.test_utils.mock_plugins import mock_plugin_manager
+from tests_common.test_utils.paths import AIRFLOW_CORE_SOURCES_PATH
 
 pytestmark = pytest.mark.db_test
 
@@ -320,3 +326,61 @@ class TestUiTranslationViews:
         response = self._client([TranslationPlugin()], 
tmp_path).get("/static/i18n/languages.json")
 
         assert response.headers["cache-control"] == "no-cache"
+
+
+class TestBaseReactAppPreload:
+    def test_lists_only_global_base_react_apps(self):
+        class BasePlugin(AirflowPlugin):
+            name = "base_plugin"
+            react_apps = [
+                {"name": "Bar", "bundle_url": "/bar/main.js", "destination": 
"base"},
+                {"name": "Tab", "bundle_url": "/bar/tab.js", "destination": 
"dag"},
+            ]
+
+        class TeamPlugin(AirflowPlugin):
+            name = "team_plugin"
+            team_name = "team_a"
+            react_apps = [{"name": "TeamBar", "bundle_url": "/team/main.js", 
"destination": "base"}]
+
+        with mock_plugin_manager(plugins=[BasePlugin(), TeamPlugin()]):
+            assert base_react_app_bundle_urls() == ["/bar/main.js"]
+
+    @staticmethod
+    def _render(template: str, preload_bundle_urls: list[str]) -> str:
+        app = FastAPI()
+        templates = Jinja2Templates(directory=AIRFLOW_CORE_SOURCES_PATH / 
"airflow/ui")
+
+        @app.get("/")
+        def index(request: Request):
+            return templates.TemplateResponse(
+                request,
+                template,
+                {"backend_server_base_url": "/prefix/", "preload_bundle_urls": 
preload_bundle_urls},
+            )
+
+        return TestClient(app).get("/").text
+
+    def test_lists_nothing_without_base_react_apps(self):
+        class NavPlugin(AirflowPlugin):
+            name = "nav_plugin"
+            react_apps = [{"name": "Page", "bundle_url": "/page/main.js", 
"destination": "nav"}]
+
+        with mock_plugin_manager(plugins=[NavPlugin()]):
+            assert base_react_app_bundle_urls() == []
+
+    @pytest.mark.parametrize("template", ["index.html", "dev/index.html"])
+    def test_index_preloads_each_bundle_after_the_base_tag(self, template):
+        html = self._render(template, ["/bar/main.js?a=1&b=2", 
"https://cdn.example.com/x.js";])
+
+        assert '<link rel="modulepreload" href="/bar/main.js?a=1&amp;b=2" />' 
in html
+        assert '<link rel="modulepreload" href="https://cdn.example.com/x.js"; 
/>' in html
+        assert html.index("<base ") < html.index('rel="modulepreload"')
+
+    @pytest.mark.parametrize("template", ["index.html", "dev/index.html"])
+    def test_index_has_no_preload_without_base_bundles(self, template):
+        assert "modulepreload" not in self._render(template, [])
+
+    def test_index_escapes_a_bundle_url(self):
+        html = self._render("index.html", ['/x.js" onload="alert(1)'])
+
+        assert 'onload="alert(1)"' not in html

Reply via email to