From 344717d07d163ef7f61c4d799144163f6c7d4f02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Apr 2021 18:17:30 -1000 Subject: [PATCH] Reduce time to first byte for frontend index (#49396) Cache template and manifest.json generation --- homeassistant/components/frontend/__init__.py | 171 ++++++++++++------ tests/components/frontend/test_init.py | 42 +++-- 2 files changed, 141 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 0529fd6dbb2..ed339b9dc8b 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,6 +1,7 @@ """Handle the frontend for Home Assistant.""" from __future__ import annotations +from functools import lru_cache import json import logging import mimetypes @@ -45,37 +46,6 @@ EVENT_PANELS_UPDATED = "panels_updated" DEFAULT_THEME_COLOR = "#03A9F4" -MANIFEST_JSON = { - "background_color": "#FFFFFF", - "description": "Home automation platform that puts local control and privacy first.", - "dir": "ltr", - "display": "standalone", - "icons": [ - { - "src": f"/static/icons/favicon-{size}x{size}.png", - "sizes": f"{size}x{size}", - "type": "image/png", - "purpose": "maskable any", - } - for size in (192, 384, 512, 1024) - ], - "screenshots": [ - { - "src": "/static/images/screenshots/screenshot-1.png", - "sizes": "413x792", - "type": "image/png", - } - ], - "lang": "en-US", - "name": "Home Assistant", - "short_name": "Assistant", - "start_url": "/?homescreen=1", - "theme_color": DEFAULT_THEME_COLOR, - "prefer_related_applications": True, - "related_applications": [ - {"platform": "play", "id": "io.homeassistant.companion.android"} - ], -} DATA_PANELS = "frontend_panels" DATA_JS_VERSION = "frontend_js_version" @@ -124,6 +94,88 @@ SERVICE_SET_THEME = "set_theme" SERVICE_RELOAD_THEMES = "reload_themes" +class Manifest: + """Manage the manifest.json contents.""" + + def __init__(self, data: dict) -> None: + """Init the manifest manager.""" + self.manifest = data + self._serialize() + + def __getitem__(self, key: str) -> Any: + """Return an item in the manifest.""" + return self.manifest[key] + + @property + def json(self) -> str: + """Return the serialized manifest.""" + return self._serialized + + def _serialize(self) -> None: + self._serialized = json.dumps(self.manifest, sort_keys=True) + + def update_key(self, key: str, val: str) -> None: + """Add a keyval to the manifest.json.""" + self.manifest[key] = val + self._serialize() + + +MANIFEST_JSON = Manifest( + { + "background_color": "#FFFFFF", + "description": "Home automation platform that puts local control and privacy first.", + "dir": "ltr", + "display": "standalone", + "icons": [ + { + "src": f"/static/icons/favicon-{size}x{size}.png", + "sizes": f"{size}x{size}", + "type": "image/png", + "purpose": "maskable any", + } + for size in (192, 384, 512, 1024) + ], + "screenshots": [ + { + "src": "/static/images/screenshots/screenshot-1.png", + "sizes": "413x792", + "type": "image/png", + } + ], + "lang": "en-US", + "name": "Home Assistant", + "short_name": "Assistant", + "start_url": "/?homescreen=1", + "theme_color": DEFAULT_THEME_COLOR, + "prefer_related_applications": True, + "related_applications": [ + {"platform": "play", "id": "io.homeassistant.companion.android"} + ], + } +) + + +class UrlManager: + """Manage urls to be used on the frontend. + + This is abstracted into a class because + some integrations add a remove these directly + on hass.data + """ + + def __init__(self, urls): + """Init the url manager.""" + self.urls = frozenset(urls) + + def add(self, url): + """Add a url to the set.""" + self.urls = frozenset([*self.urls, url]) + + def remove(self, url): + """Remove a url from the set.""" + self.urls = self.urls - {url} + + class Panel: """Abstract class for panels.""" @@ -223,15 +275,12 @@ def async_remove_panel(hass, frontend_url_path): def add_extra_js_url(hass, url, es5=False): """Register extra js or module url to load.""" key = DATA_EXTRA_JS_URL_ES5 if es5 else DATA_EXTRA_MODULE_URL - url_set = hass.data.get(key) - if url_set is None: - url_set = hass.data[key] = set() - url_set.add(url) + hass.data[key].add(url) def add_manifest_json_key(key, val): """Add a keyval to the manifest.json.""" - MANIFEST_JSON[key] = val + MANIFEST_JSON.update_key(key, val) def _frontend_root(dev_repo_path): @@ -311,17 +360,8 @@ async def async_setup(hass, config): sidebar_icon="hass:hammer", ) - if DATA_EXTRA_MODULE_URL not in hass.data: - hass.data[DATA_EXTRA_MODULE_URL] = set() - - for url in conf.get(CONF_EXTRA_MODULE_URL, []): - add_extra_js_url(hass, url) - - if DATA_EXTRA_JS_URL_ES5 not in hass.data: - hass.data[DATA_EXTRA_JS_URL_ES5] = set() - - for url in conf.get(CONF_EXTRA_JS_URL_ES5, []): - add_extra_js_url(hass, url, True) + hass.data[DATA_EXTRA_MODULE_URL] = UrlManager(conf.get(CONF_EXTRA_MODULE_URL, [])) + hass.data[DATA_EXTRA_JS_URL_ES5] = UrlManager(conf.get(CONF_EXTRA_JS_URL_ES5, [])) await _async_setup_themes(hass, conf.get(CONF_THEMES)) @@ -353,12 +393,16 @@ async def _async_setup_themes(hass, themes): """Update theme_color in manifest.""" name = hass.data[DATA_DEFAULT_THEME] themes = hass.data[DATA_THEMES] - MANIFEST_JSON["theme_color"] = DEFAULT_THEME_COLOR if name != DEFAULT_THEME: - MANIFEST_JSON["theme_color"] = themes[name].get( - "app-header-background-color", - themes[name].get(PRIMARY_COLOR, DEFAULT_THEME_COLOR), + MANIFEST_JSON.update_key( + "theme_color", + themes[name].get( + "app-header-background-color", + themes[name].get(PRIMARY_COLOR, DEFAULT_THEME_COLOR), + ), ) + else: + MANIFEST_JSON.update_key("theme_color", DEFAULT_THEME_COLOR) hass.bus.async_fire(EVENT_THEMES_UPDATED) @callback @@ -426,6 +470,12 @@ async def _async_setup_themes(hass, themes): ) +@callback +@lru_cache(maxsize=1) +def _async_render_index_cached(template, **kwargs): + return template.render(**kwargs) + + class IndexView(web_urldispatcher.AbstractResource): """Serve the frontend.""" @@ -504,16 +554,16 @@ class IndexView(web_urldispatcher.AbstractResource): if not hass.components.onboarding.async_is_onboarded(): return web.Response(status=302, headers={"location": "/onboarding.html"}) - template = self._template_cache - - if template is None: - template = await hass.async_add_executor_job(self.get_template) + template = self._template_cache or await hass.async_add_executor_job( + self.get_template + ) return web.Response( - text=template.render( + text=_async_render_index_cached( + template, theme_color=MANIFEST_JSON["theme_color"], - extra_modules=hass.data[DATA_EXTRA_MODULE_URL], - extra_js_es5=hass.data[DATA_EXTRA_JS_URL_ES5], + extra_modules=hass.data[DATA_EXTRA_MODULE_URL].urls, + extra_js_es5=hass.data[DATA_EXTRA_JS_URL_ES5].urls, ), content_type="text/html", ) @@ -537,8 +587,9 @@ class ManifestJSONView(HomeAssistantView): @callback def get(self, request): # pylint: disable=no-self-use """Return the manifest.json.""" - msg = json.dumps(MANIFEST_JSON, sort_keys=True) - return web.Response(text=msg, content_type="application/manifest+json") + return web.Response( + text=MANIFEST_JSON.json, content_type="application/manifest+json" + ) @callback diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 0e8e31bb20d..fe624452475 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -10,27 +10,26 @@ from homeassistant.components.frontend import ( CONF_EXTRA_HTML_URL_ES5, CONF_JS_VERSION, CONF_THEMES, + DEFAULT_THEME_COLOR, DOMAIN, EVENT_PANELS_UPDATED, THEMES_STORAGE_KEY, ) from homeassistant.components.websocket_api.const import TYPE_RESULT -from homeassistant.const import HTTP_NOT_FOUND +from homeassistant.const import HTTP_NOT_FOUND, HTTP_OK from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component from homeassistant.util import dt from tests.common import async_capture_events, async_fire_time_changed -CONFIG_THEMES = { - DOMAIN: { - CONF_THEMES: { - "happy": {"primary-color": "red"}, - "dark": {"primary-color": "black"}, - } - } +MOCK_THEMES = { + "happy": {"primary-color": "red", "app-header-background-color": "blue"}, + "dark": {"primary-color": "black"}, } +CONFIG_THEMES = {DOMAIN: {CONF_THEMES: MOCK_THEMES}} + @pytest.fixture async def ignore_frontend_deps(hass): @@ -148,10 +147,7 @@ async def test_themes_api(hass, themes_ws_client): assert msg["result"]["default_theme"] == "default" assert msg["result"]["default_dark_theme"] is None - assert msg["result"]["themes"] == { - "happy": {"primary-color": "red"}, - "dark": {"primary-color": "black"}, - } + assert msg["result"]["themes"] == MOCK_THEMES # safe mode hass.config.safe_mode = True @@ -474,3 +470,25 @@ async def test_static_paths(hass, mock_http_client): ) assert resp.status == 302 assert resp.headers["location"] == "/profile" + + +async def test_manifest_json(hass, frontend_themes, mock_http_client): + """Test for fetching manifest.json.""" + resp = await mock_http_client.get("/manifest.json") + assert resp.status == HTTP_OK + assert "cache-control" not in resp.headers + + json = await resp.json() + assert json["theme_color"] == DEFAULT_THEME_COLOR + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "happy"}, blocking=True + ) + await hass.async_block_till_done() + + resp = await mock_http_client.get("/manifest.json") + assert resp.status == HTTP_OK + assert "cache-control" not in resp.headers + + json = await resp.json() + assert json["theme_color"] != DEFAULT_THEME_COLOR