PERF: Refactor and optimize splash screen implementation

- Remove JS

- Remove "Loading..." text. This has been been broken for a while due to some conflicting discourse-ai CSS. Also, animating the `content:` property like this requires the browser to repaint/reflow, which cannot be done while JS is executing.

- Replace animated SVG with divs animated via CSS. When JS is executing, browsers pause animations of transform properties inside SVGs. This limitation does not exist on regular CSS animations. So with this change, the animation continues smoothly even you run an infinite loop in JS.

  To ensure the splash screen remains "contentful" for LCP purposes, an SVG background-image is used

There is no change to the visual look of the animation
This commit is contained in:
David Taylor 2024-12-19 11:56:01 +00:00
parent dc3379430d
commit b189aed2d2
No known key found for this signature in database
GPG Key ID: 46904C18B1D3F434
4 changed files with 81 additions and 267 deletions

View File

@ -56,8 +56,7 @@ class Discourse extends Application {
ready() { ready() {
performance.mark("discourse-ready"); performance.mark("discourse-ready");
const event = new CustomEvent("discourse-ready"); document.querySelector("#d-splash")?.remove();
document.dispatchEvent(event);
} }
} }

View File

@ -1,66 +0,0 @@
// This script is inlined in `_discourse_splash.html.erb
const DELAY_TARGET = 2000;
const POLLING_INTERVAL = 50;
const splashSvgTemplate = document.querySelector(".splash-svg-template");
const splashTemplateClone = splashSvgTemplate.content.cloneNode(true);
const svgElement = splashTemplateClone.querySelector("svg");
const svgString = new XMLSerializer().serializeToString(svgElement);
const encodedSvg = btoa(svgString);
const splashWrapper = document.querySelector("#d-splash");
const splashImage =
splashWrapper && splashWrapper.querySelector(".preloader-image");
if (splashImage) {
splashImage.src = `data:image/svg+xml;base64,${encodedSvg}`;
const connectStart = performance.timing.connectStart || 0;
const targetTime = connectStart + DELAY_TARGET;
let splashInterval;
let discourseReady;
const swapSplash = () => {
splashWrapper &&
splashWrapper.style.setProperty("--animation-state", "running");
svgElement && svgElement.style.setProperty("--animation-state", "running");
const newSvgString = new XMLSerializer().serializeToString(svgElement);
const newEncodedSvg = btoa(newSvgString);
splashImage.src = `data:image/svg+xml;base64,${newEncodedSvg}`;
performance.mark("discourse-splash-visible");
clearSplashInterval();
};
const clearSplashInterval = () => {
clearInterval(splashInterval);
splashInterval = null;
};
(() => {
splashInterval = setInterval(() => {
if (discourseReady) {
clearSplashInterval();
}
if (Date.now() > targetTime) {
swapSplash();
}
}, POLLING_INTERVAL);
})();
document.addEventListener(
"discourse-ready",
() => {
discourseReady = true;
splashWrapper && splashWrapper.remove();
performance.mark("discourse-splash-removed");
},
{ once: true }
);
}

View File

@ -1,23 +0,0 @@
# frozen_string_literal: true
module SplashScreenHelper
def self.raw_js
if Rails.env.development?
load_js
else
@loaded_js ||= load_js
end.html_safe
end
private
def self.load_js
File.read("#{Rails.root}/app/assets/javascripts/discourse/dist/assets/splash-screen.js").sub(
"//# sourceMappingURL=splash-screen.map",
"",
)
rescue Errno::ENOENT
Rails.logger.error("Unable to load splash screen JS") if Rails.env.production?
"console.log('Unable to load splash screen JS')"
end
end

View File

@ -1,114 +1,19 @@
<%- unless customization_disabled? %> <%
<section id="d-splash"> empty_svg = <<~HTML
<template class="splash-svg-template">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1" version="1.1"
> >
<style> <!--
:root { LCP candidate image
--animation-state: paused; #{"." * 5000}
} -->
/* user picked a theme where the "regular" scheme is dark */
<%- if dark_color_scheme? %>
:root {
--primary: #<%= ColorScheme.hex_for_name("primary", scheme_id) %>;
--secondary: #<%= ColorScheme.hex_for_name("secondary", scheme_id) %>;
--tertiary: #<%= ColorScheme.hex_for_name("tertiary", scheme_id) %>;
--highlight: #<%= ColorScheme.hex_for_name("highlight", scheme_id) %>;
--success: #<%= ColorScheme.hex_for_name("success", scheme_id) %>;
}
<%- else %>
/* user picked a theme a light scheme and also enabled a dark scheme */
/* deal with light scheme first */
@media (prefers-color-scheme: light) {
:root {
--primary: #<%= ColorScheme.hex_for_name("primary", scheme_id) %>;
--secondary: #<%= ColorScheme.hex_for_name("secondary", scheme_id) %>;
--tertiary: #<%= ColorScheme.hex_for_name("tertiary", scheme_id) %>;
--highlight: #<%= ColorScheme.hex_for_name("highlight", scheme_id) %>;
--success: #<%= ColorScheme.hex_for_name("success", scheme_id) %>;
}
}
/* then deal with dark scheme */
@media (prefers-color-scheme: dark) {
:root {
--primary: #<%= ColorScheme.hex_for_name("primary", dark_scheme_id) %>;
--secondary: #<%= ColorScheme.hex_for_name("secondary", dark_scheme_id) %>;
--tertiary: #<%= ColorScheme.hex_for_name("tertiary", dark_scheme_id) %>;
--highlight: #<%= ColorScheme.hex_for_name("highlight", dark_scheme_id) %>;
--success: #<%= ColorScheme.hex_for_name("success", dark_scheme_id) %>;
}
}
<%- end %>
/* these styles need to live here because the SVG has a different scope */
.dots {
animation-name: loader;
animation-timing-function: ease-in-out;
animation-duration: 3s;
animation-iteration-count: infinite;
animation-play-state: var(--animation-state);
stroke: #fff;
stroke-width: 0.5px;
transform-origin: center;
opacity: 0;
r: max(1vw, 11px);
cy: 50%;
filter: saturate(2) opacity(0.85);
fill: var(--tertiary);
}
.dots:nth-child(2) {
animation-delay: 0.15s;
}
.dots:nth-child(3) {
animation-delay: 0.3s;
}
.dots:nth-child(4) {
animation-delay: 0.45s;
}
.dots:nth-child(5) {
animation-delay: 0.6s;
}
@keyframes loader {
0% {
opacity: 0;
transform: scale(1);
}
45% {
opacity: 1;
transform: scale(0.7);
}
65% {
opacity: 1;
transform: scale(0.7);
}
100% {
opacity: 0;
transform: scale(1);
}
}
</style>
<g class="container">
<circle class="dots" cx="30vw" />
<circle class="dots" cx="40vw" />
<circle class="dots" cx="50vw" />
<circle class="dots" cx="60vw" />
<circle class="dots" cx="70vw" />
</g>
</svg> </svg>
</template> HTML
%>
<%- unless customization_disabled? %>
<section id="d-splash">
<style> <style>
html { html {
overflow-y: hidden !important; overflow-y: hidden !important;
@ -116,81 +21,59 @@
/* user picked a theme where the "regular" scheme is dark */ /* user picked a theme where the "regular" scheme is dark */
<%- if dark_color_scheme? %> <%- if dark_color_scheme? %>
html {
background-color: #<%= ColorScheme.hex_for_name("secondary", scheme_id) %>;
}
#d-splash .preloader-text-wrapper {
color: #<%= ColorScheme.hex_for_name("primary", scheme_id) %>;
}
<%- else %>
/* user picked a theme a light scheme and also enabled a dark scheme */
/* deal with light scheme first */
@media (prefers-color-scheme: light) {
html { html {
background-color: #<%= ColorScheme.hex_for_name("secondary", scheme_id) %>; background-color: #<%= ColorScheme.hex_for_name("secondary", scheme_id) %>;
} }
#d-splash .preloader-text-wrapper { #d-splash {
color: #<%= ColorScheme.hex_for_name("primary", scheme_id) %>; --dot-color: #<%= ColorScheme.hex_for_name("tertiary", scheme_id) %>;
} }
} <%- else %>
/* user picked a theme a light scheme and also enabled a dark scheme */
/* then deal with dark scheme */ /* deal with light scheme first */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: light) {
html { html {
background-color: #<%= ColorScheme.hex_for_name("secondary", dark_scheme_id) %>; background-color: #<%= ColorScheme.hex_for_name("secondary", scheme_id) %>;
}
#d-splash {
--dot-color: #<%= ColorScheme.hex_for_name("tertiary", scheme_id) %>;
}
} }
#d-splash .preloader-text-wrapper { /* then deal with dark scheme */
color: #<%= ColorScheme.hex_for_name("primary", dark_scheme_id) %>; @media (prefers-color-scheme: dark) {
html {
background-color: #<%= ColorScheme.hex_for_name("secondary", dark_scheme_id) %>;
}
#d-splash {
--dot-color: #<%= ColorScheme.hex_for_name("tertiary", dark_scheme_id) %>;
}
} }
}
<%- end %> <%- end %>
#d-splash { #d-splash {
display: grid; display: grid;
place-items: center; place-items: center;
backface-visibility: hidden;
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
width: 100%; width: 100vw;
z-index: 1001; z-index: 1001;
--animation-state: paused;
} }
#d-splash .preloader-image { #d-splash .preloader-image {
max-width: 100%; --splash-dot-size: max(1vw, 25px);
--splash-dot-spacing: calc(var(--splash-dot-size) * 1.5);
width: calc((var(--splash-dot-size) + var(--splash-dot-spacing)) * 5);
height: 100vh; height: 100vh;
background-image: url('data:image/svg+xml;base64,<%= Base64.strict_encode64 empty_svg %>');
background-size: cover;
} }
#d-splash .preloader-text-wrapper { @keyframes d-splash-fade-in {
font-family: sans-serif;
position: absolute;
opacity: 0;
animation: fade-in 0.5s ease-in-out;
animation-delay: 1s;
animation-fill-mode: forwards;
animation-play-state: var(--animation-state);
margin-bottom: -4em;
}
#d-splash .preloader-text:after {
animation: loading-text 3s infinite;
content: "";
position: absolute;
margin: 0 0.1em;
left: 100%;
}
.rtl #d-splash .preloader-text:after {
left: 0;
right: 100%;
}
@keyframes fade-in {
0% { 0% {
opacity: 0; opacity: 0;
} }
@ -199,30 +82,55 @@
} }
} }
@keyframes loading-text { .dots {
animation-name: d-splash-loader;
animation-timing-function: ease-in-out;
animation-duration: 3s;
animation-iteration-count: infinite;
animation-delay: calc(var(--n) * 0.15s);
position: absolute;
top: calc(50% - var(--splash-dot-size) / 2);
left: calc((50% - var(--splash-dot-size) / 2) + (var(--n) * var(--splash-dot-spacing)));
transform-origin: calc((var(--splash-dot-spacing) * var(--n) * -1) + var(--splash-dot-size)/2) center;
width: var(--splash-dot-size);
height: var(--splash-dot-size);
border-radius: 50%;
border: 0.5px solid #fff;
background-color: var(--dot-color);
filter: saturate(2) opacity(0.85);
opacity: 0;
}
@keyframes d-splash-loader {
0% { 0% {
content: ""; opacity: 0;
transform: scale(1);
} }
25% { 45% {
content: "."; opacity: 1;
transform: scale(0.7);
} }
50% { 65% {
content: ".."; opacity: 1;
transform: scale(0.7);
} }
75% { 100% {
content: "..."; opacity: 0;
transform: scale(1);
} }
} }
</style> </style>
<img <div class="preloader-image" elementtiming="discourse-splash-visible">
class="preloader-image" <div class="dots" style="--n:-2;"></div>
src="data:image/svg;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" <div class="dots" style="--n:-1;"></div>
alt="<%=SiteSetting.title%>" <div class="dots" style="--n:0;"></div>
/> <div class="dots" style="--n:1;"></div>
<div class="dots" style="--n:2;"></div>
<div class="preloader-text-wrapper">
<div class="preloader-text"><%= I18n.t("js.preloader_text") %></div>
</div> </div>
<noscript> <noscript>
@ -236,9 +144,5 @@
} }
</style> </style>
</noscript> </noscript>
<script nonce="<%= csp_nonce_placeholder %>">
<%= SplashScreenHelper.raw_js %>
</script>
</section> </section>
<%- end %> <%- end %>