Add FOUCE utilities (#686)

* add fouce utilities

* add comment

* Update docs/docs/installation.md

Co-authored-by: Lea Verou <lea@verou.me>

* commit PR suggestion

* rename wa-reduce-fouce to wa-cloak

* remove class as requested

* add cloak class

* wait a cycle

* move turbo to same file

* reduce fade

* disable SSR and add Turbo FOUCE helper

* disable SSR

* fix test suite

* workflow dispatch

* update fouce util

* no need to remove cloak class

* simplify fouce util

* add allDefined util

* update changelog

---------

Co-authored-by: Lea Verou <lea@verou.me>
Co-authored-by: konnorrogers <konnor5456@gmail.com>
This commit is contained in:
Cory LaViska
2025-03-24 16:33:24 -04:00
committed by GitHub
parent 59dcaaff83
commit fcfe2bde7d
14 changed files with 168 additions and 158 deletions

View File

@@ -1,11 +1,12 @@
# # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: SSR Tests
on:
push:
branches: [next]
# push:
# branches: [next]
workflow_dispatch:
jobs:
ssr_test:

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" data-fa-kit-code="b10bfbde90" data-cdn-url="{% cdnUrl %}">
<html lang="en" data-fa-kit-code="b10bfbde90" data-cdn-url="{% cdnUrl %}" class="wa-cloak">
<head>
{% include 'head.njk' %}
<meta name="theme-color" content="#f36944">

View File

@@ -23,10 +23,9 @@
<script src="/assets/scripts/hydration-errors.js"></script>
<link rel="stylesheet" href="/assets/styles/hydration-errors.css">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.10/+esm"></script>
{# Web Awesome #}
<script type="module" src="/dist/webawesome.ssr-loader.js"></script>
<script type="module" src="/dist/webawesome.loader.js"></script>
<script type="module" src="/assets/scripts/theme-picker.js"></script>
{# Preset Theme #}

View File

@@ -1,3 +1,6 @@
import 'https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.10/+esm';
import { preventTurboFouce } from '/dist/webawesome.js';
if (!window.___turboScrollPositions___) {
window.___turboScrollPositions___ = {};
}
@@ -70,3 +73,4 @@ function fixDSD(e) {
window.addEventListener('turbo:before-cache', saveScrollPosition);
window.addEventListener('turbo:before-render', restoreScrollPosition);
window.addEventListener('turbo:render', restoreScrollPosition);
preventTurboFouce();

View File

@@ -37,10 +37,6 @@ This snippet includes three parts:
Now you can [start using Web Awesome!](/docs/usage)
:::info
While convenient, autoloading may lead to a [Flash of Undefined Custom Elements](https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/). The linked article describes some ways to alleviate it.
:::
---
## Using Font Awesome Kit Codes

View File

@@ -14,6 +14,8 @@ During the alpha period, things might break! We take breaking changes very serio
## Next
- Added the `wa-cloak` utility to prevent FOUCE
- Added the `allDefined()` utility for awaiting component registration
- Fixed `wa-pill` class for text fields
- Fixed `pill` style for `<wa-input>` elements
- Fixed a bug in `<wa-color-picker>` that prevented light dismiss from working when clicking immediately above the color picker dropdown

View File

@@ -8,6 +8,30 @@ Web Awesome components are just regular HTML elements, or [custom elements](http
If you're new to custom elements, often referred to as "web components," this section will familiarize you with how to use them.
## Awaiting Registration
Unlike traditional frameworks, custom elements don't have a centralized initialization phase. This means you need to verify that a custom element has been properly registered before attempting to interact with its properties or methods.
You can use the [`customElements.whenDefined()`](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/whenDefined) method to ensure a specific component is ready:
```ts
await customElements.whenDefined('wa-button');
// <wa-button> is ready to use!
const button = document.querySelector('wa-button');
```
When working with multiple components, checking each one individually can become tedious. For convenience, Web Awesome provides the `allDefined()` function which automatically detects and waits for all Web Awesome components in the DOM to be initialized before resolving.
```ts
import { allDefined } from '/dist/webawesome.js';
// Waits for all Web Awesome components in the DOM to be registered
await allDefined();
// All Web Awesome components on the page are ready!
```
## Attributes & Properties
Many components have properties that can be set using attributes. For example, buttons accept a `size` attribute that maps to the `size` property which dictates the button's size.

View File

@@ -0,0 +1,32 @@
---
title: Reduce FOUCE
description: Utility to improve the loading experience by hiding non-prerendered custom elements until they are registered.
file: styles/utilities/fouce.css
icon: spinner
---
While convenient, autoloading can lead to a [Flash of Undefined Custom Elements](https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/).
The [FOUCE style utility](/docs/utilities/fouce/#opting-in) (which is automatically applied if you use the [Web Awesome utilities](/docs/utilities/)) takes care of hiding custom elements until they and their contents have been registered, up to a maximum of two seconds.
In many cases, this is not enough, and you may wish to hide a broader wrapper element or even the entire page until all WA elements within it have loaded. To do that, you can add the `wa-reduce-fouce` class to any element on the page or even apply it to the whole page by placing the class on the `<html>` element.
```html
<html class="wa-cloak">
...
</html>
```
As soon as all elements are registered _or_ after two seconds have elapsed, the autoloader will show the page. The two-second timeout prevents blank screens from persisting on slow networks and pages that have errors.
:::details Are you using Turbo in your app?
If you're using [Turbo](https://turbo.hotwired.dev/) to serve a multi-page application (MPA) as a single page application (SPA), you might notice FOUCE when navigating from page to page. This is because Turbo renders the new page's content before the autoloader has a change to register new components.
The following function acts as a middleware to ensure components are registered _before_ the page shows, eliminating FOUCE for page-to-page navigation with Turbo.
```js
import { preventTurboFouce } from '/dist/webawesome.js';
preventTurboFouce();
```
:::

View File

@@ -1,131 +0,0 @@
---
title: Reduce FOUCE
description: Utility to improve the loading experience by hiding non-prerendered custom elements until they are registered.
file: styles/utilities/fouce.css
icon: spinner
---
{% markdown %}
No class is needed to use this utility, it will be applied automatically as long as it its CSS is included.
Here is a comparison of the loading experience with and without this utility,
with a simulated slow loading time:
{% endmarkdown %}
<div class="wa-split wa-align-items-end">
<strong>Normal loading</strong>
<wa-button onclick="document.querySelectorAll('iframe').forEach(iframe => iframe.srcdoc = iframe.srcdoc)">
<wa-icon name="refresh"></wa-icon>
Refresh
</wa-button>
<strong>With FOUCE reduction</strong>
</div>
{% set sample_card %}
<link id="theme-stylesheet" rel="stylesheet" href="/dist/styles/themes/default.css" render="blocking" fetchpriority="high">
<link rel="stylesheet" href="/dist/styles/webawesome.css">
<link rel="stylesheet" href="/dist/styles/forms.css">
<script type=module>
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const loadScript = src => new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.type = "module";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
await delay(500);
await loadScript("/dist/components/button/button.js");
await delay(500);
await loadScript("/dist/components/card/card.js");
await delay(500);
await loadScript("/dist/components/rating/rating.js");
</script>
<wa-card with-footer with-image class="card-overview">
<img
slot="image"
src="https://images.unsplash.com/photo-1559209172-0ff8f6d49ff7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=500&q=80"
alt="A kitten sits patiently between a terracotta pot and decorative grasses."
/>
<strong>Mittens</strong><br />
This kitten is as cute as he is playful. Bring him home today!<br />
<small>6 weeks old</small>
<div slot="footer">
<wa-button variant="brand" pill>More Info</wa-button>
<wa-rating></wa-rating>
</div>
</wa-card>
<style>
.card-overview small {
color: var(--wa-color-text-quiet);
}
.card-overview [slot=footer] {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
{% endset %}
<div class="iframes">
<iframe srcdoc='<body class="wa-fouce-off">{{ sample_card }}</body>'></iframe>
<iframe srcdoc='{{ sample_card }}'></iframe>
</div>
<style>
.iframes {
display: flex;
gap: var(--wa-space-m);
margin-top: var(--wa-space-l);
iframe {
flex: 1;
height: 60ch;
}
}
</style>
{% markdown %}
## How does it work?
The utility consists of a timeout (`2s` by default) and a fade duration (`200ms` by default).
- If the element is _ready_ before the timeout, it will appear immediately.
- If it takes longer than _timeout_ + _fade_, it will fade in over the fade duration.
- If it takes somewhere between _timeout_ and _timeout_ + _fade_, you will get an interrupted fade.
An element is considered ready when both of these are true:
1. Either It has been registered or has a `did-ssr` attribute (indicating it was pre-rendered)
2. If its a Web Awesome component, its contents are also ready
## Customization
You can use the following CSS variables to customize the behavior:
| Variable | Description | Default |
| --- | --- | --- |
| `--wa-fouce-fade` | The transition duration for the fade effect. | `200ms` |
| `--wa-fouce-timeout` | The timeout after which elements will appear even if not registered | `2s` |
The fade duration cannot be longer than the timeout.
This means that you can disable FOUCE reduction on an element by setting `--wa-fouce-timeout: 0s`.
For example, if instead of `did-ssr` you used an `ssr` attribute to mark elements that were pre-rendered, you can do this to get them to appear immediately:
```css
[ssr] {
--wa-fouce-timeout: 0s;
}
```
You can also opt-out from FOUCE reduction for an element and its contents by adding the `.wa-fouce-off` class to it.
Applying this class to the root element will disable the utility for the entire page.
{% endmarkdown %}

View File

@@ -1,20 +1,17 @@
/*
* Utility to minimize FOUCE and show custom elements only after they're registered
*/
:not(:defined),
:state(wa-defined):has(:not(:defined)),
.wa-cloak:has(:not(:defined)) {
animation: 2s step-end wa-fouce-cloak;
}
@keyframes wa-fade-in {
@keyframes wa-fouce-cloak {
from {
opacity: 0;
}
}
:not(:defined),
:state(wa-defined):has(:not(:defined)) {
/* The clamp() ensures that if --wa-fouce-timeout is set to 0s, the whole effect is disabled */
--wa-fouce-animation: clamp(0s, var(--wa-fouce-fade, 200ms), var(--wa-fouce-timeout, 2s)) var(--wa-fouce-timeout, 2s)
wa-fade-in both;
&:where(:not([did-ssr], .wa-fouce-off, .wa-fouce-off *)) {
animation: var(--wa-fouce-animation);
to {
opacity: 1;
}
}

View File

@@ -50,6 +50,12 @@ export async function discover(root: Element | ShadowRoot) {
console.warn(imp.reason); // eslint-disable-line no-console
}
}
// Wait a cycle to allow the first Lit update to run
await new Promise(requestAnimationFrame);
// Dispatch an event when discovery is complete.
document.dispatchEvent(new CustomEvent('wa-discovery-complete'));
}
/**
@@ -69,3 +75,22 @@ function register(tagName: string): Promise<void> {
import(path).then(() => resolve()).catch(() => reject(new Error(`Unable to autoload <${tagName}> from ${path}`)));
});
}
/**
* Acts as a middleware for Turbo's `turbo:before-render` event to ensure components are auto-loaded before showing the
* next page, eliminating page-to-page FOUCE in a Turbo environment.
*/
export function preventTurboFouce(timeout = 2000) {
document.addEventListener('turbo:before-render', async (event: CustomEvent) => {
const newBody = event.detail.newBody;
event.preventDefault();
try {
// Wait until all elements are registered or two seconds, whichever comes first
await Promise.race([discover(newBody), new Promise(resolve => setTimeout(resolve, timeout))]);
} finally {
event.detail.resume();
}
});
}

64
src/utilities/defined.ts Normal file
View File

@@ -0,0 +1,64 @@
interface AllDefinedOptions {
/**
* A callback that accepts a custom element tag name and returns `true` if the custom element should be defined before
* resolving or `false` to skip it. The tag name is always in lowercase.
*/
match: (tagName: string) => boolean;
/**
* To wait for additional custom elements that may not be on the page when the function is called, provide them here.
*/
additionalElements: string | string[];
/**
* The root in which to look for custom elements. Defaults to `document`. By design, shadow roots are not traversed,
* but you can call this function and set `root` to a custom element's shadow root if needed.
*/
root: Document | ShadowRoot;
}
/**
* Waits for custom elements that are currently on the page to be registered before resolving. This is sugar for
* awaiting `customElements.whenDefined()` multiple times. By default, the function waits for all undefined Web Awesome
* elements, but you can pass a custom match function to wait for other custom elements instead.
*
* The function returns with `Promise.all()`, so any loading errors will cause it to reject. Make sure you handle errors
* accordingly using a try/catch block or a `.catch()`.
*
* @example
* // Wait for Web Awesome elements
* await allDefined();
*
* // Wait for all custom elements that start with `foo-` as well as the `<bar-button>` element
* await allDefined({
* match: tagName => tagName.startsWith('foo-'),
* additionalElements: ['bar-button', 'baz-dialog']
* });
*/
export async function allDefined(options?: Partial<AllDefinedOptions>) {
const opts: AllDefinedOptions = {
match: tagName => tagName.startsWith('wa-'),
additionalElements: [],
root: document,
...options,
};
// Ensure additional elements is an array
const additionalElements = Array.isArray(opts.additionalElements)
? opts.additionalElements
: [opts.additionalElements];
// Discover undefined elements in the document
const undefinedElements = [...opts.root.querySelectorAll(':not(:defined)')]
.map(el => el.localName)
.filter((tag, index, arr) => arr.indexOf(tag) === index) // make it unique
.filter(tag => opts.match(tag)); // make sure it matches
const tagsToAwait = [...undefinedElements, ...additionalElements];
// Wait for all to be registered
await Promise.all(tagsToAwait.map(tag => customElements.whenDefined(tag)));
// Wait a cycle for the first update
await new Promise(requestAnimationFrame);
}

View File

@@ -1,6 +1,7 @@
export { registerIconLibrary, unregisterIconLibrary } from './components/icon/library.js';
export { discover, startLoader, stopLoader } from './utilities/autoloader.js';
export { discover, preventTurboFouce, startLoader, stopLoader } from './utilities/autoloader.js';
export { getBasePath, getKitCode, setBasePath, setKitCode } from './utilities/base-path.js';
export { allDefined } from './utilities/defined.js';
export { registerTranslation } from './utilities/localize.js';
// Utilities

View File

@@ -1,4 +1,3 @@
import { litSsrPlugin } from '@lit-labs/testing/web-test-runner-ssr-plugin.js';
import { esbuildPlugin } from '@web/dev-server-esbuild';
import { playwrightLauncher } from '@web/test-runner-playwright';
import { readFileSync } from 'fs';
@@ -53,7 +52,6 @@ export default {
ts: true,
target: 'es2020',
}),
litSsrPlugin(),
],
browsers: [
playwrightLauncher({ product: 'chromium', concurrency }),
@@ -78,12 +76,10 @@ export default {
${componentImports.map(str => `"${str}"`).join(',\n')}
]
window.SSR_ONLY = ${process.env['SSR_ONLY'] === 'true'}
window.CSR_ONLY = ${process.env['CSR_ONLY'] === 'true'}
</script>
<script type="module">
;(async () => {
await import('@lit-labs/ssr-client/lit-element-hydrate-support.js')
await Promise.allSettled(window.clientComponents.map(str => import(str)));
})()
</script>