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&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