From fcfe2bde7d43db5e963e4c8383c78896b4f6bdf6 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Mon, 24 Mar 2025 16:33:24 -0400 Subject: [PATCH] Add FOUCE utilities (#686) * add fouce utilities * add comment * Update docs/docs/installation.md Co-authored-by: Lea Verou * 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 Co-authored-by: konnorrogers --- .github/workflows/ssr_tests.js.yml | 9 +- docs/_includes/base.njk | 2 +- docs/_includes/head.njk | 3 +- docs/assets/scripts/turbo.js | 4 + docs/docs/installation.md | 4 - docs/docs/resources/changelog.md | 2 + docs/docs/usage.md | 24 ++++++ docs/docs/utilities/fouce.md | 32 +++++++ docs/docs/utilities/fouce.njk | 131 ----------------------------- src/styles/utilities/fouce.css | 19 ++--- src/utilities/autoloader.ts | 25 ++++++ src/utilities/defined.ts | 64 ++++++++++++++ src/webawesome.ts | 3 +- web-test-runner.config.js | 4 - 14 files changed, 168 insertions(+), 158 deletions(-) create mode 100644 docs/docs/utilities/fouce.md delete mode 100644 docs/docs/utilities/fouce.njk create mode 100644 src/utilities/defined.ts diff --git a/.github/workflows/ssr_tests.js.yml b/.github/workflows/ssr_tests.js.yml index 1b188eaa2..a5944d604 100644 --- a/.github/workflows/ssr_tests.js.yml +++ b/.github/workflows/ssr_tests.js.yml @@ -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: diff --git a/docs/_includes/base.njk b/docs/_includes/base.njk index 6cbfea2de..21bfd33a6 100644 --- a/docs/_includes/base.njk +++ b/docs/_includes/base.njk @@ -1,5 +1,5 @@ - + {% include 'head.njk' %} diff --git a/docs/_includes/head.njk b/docs/_includes/head.njk index 89a303d24..5a9808f7b 100644 --- a/docs/_includes/head.njk +++ b/docs/_includes/head.njk @@ -23,10 +23,9 @@ - {# Web Awesome #} - + {# Preset Theme #} diff --git a/docs/assets/scripts/turbo.js b/docs/assets/scripts/turbo.js index fb1d0ea0f..e00e470c3 100644 --- a/docs/assets/scripts/turbo.js +++ b/docs/assets/scripts/turbo.js @@ -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(); diff --git a/docs/docs/installation.md b/docs/docs/installation.md index 473e4ab1f..b142497bc 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -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 diff --git a/docs/docs/resources/changelog.md b/docs/docs/resources/changelog.md index 633e61af2..474aa3600 100644 --- a/docs/docs/resources/changelog.md +++ b/docs/docs/resources/changelog.md @@ -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 `` elements - Fixed a bug in `` that prevented light dismiss from working when clicking immediately above the color picker dropdown diff --git a/docs/docs/usage.md b/docs/docs/usage.md index aea75ebc4..651ade28e 100644 --- a/docs/docs/usage.md +++ b/docs/docs/usage.md @@ -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'); + +// 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. diff --git a/docs/docs/utilities/fouce.md b/docs/docs/utilities/fouce.md new file mode 100644 index 000000000..e80173065 --- /dev/null +++ b/docs/docs/utilities/fouce.md @@ -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 `` element. + +```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(); +``` +::: diff --git a/docs/docs/utilities/fouce.njk b/docs/docs/utilities/fouce.njk deleted file mode 100644 index 23669b9dc..000000000 --- a/docs/docs/utilities/fouce.njk +++ /dev/null @@ -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 %} - - - - - -{% set sample_card %} - - - - - - - A kitten sits patiently between a terracotta pot and decorative grasses. - - Mittens
- This kitten is as cute as he is playful. Bring him home today!
- 6 weeks old - -
- More Info - -
-
- - -{% endset %} - - -
- - -
- - -{% 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 it’s 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 %} diff --git a/src/styles/utilities/fouce.css b/src/styles/utilities/fouce.css index 75e44d807..84ab08e48 100644 --- a/src/styles/utilities/fouce.css +++ b/src/styles/utilities/fouce.css @@ -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; } } diff --git a/src/utilities/autoloader.ts b/src/utilities/autoloader.ts index 4ff6463ad..c40127ce5 100644 --- a/src/utilities/autoloader.ts +++ b/src/utilities/autoloader.ts @@ -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 { 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(); + } + }); +} diff --git a/src/utilities/defined.ts b/src/utilities/defined.ts new file mode 100644 index 000000000..277693085 --- /dev/null +++ b/src/utilities/defined.ts @@ -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 `` element + * await allDefined({ + * match: tagName => tagName.startsWith('foo-'), + * additionalElements: ['bar-button', 'baz-dialog'] + * }); + */ +export async function allDefined(options?: Partial) { + 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); +} diff --git a/src/webawesome.ts b/src/webawesome.ts index ae2a4bee2..287f9d02a 100644 --- a/src/webawesome.ts +++ b/src/webawesome.ts @@ -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 diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 0ce1a4570..599e2f43e 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -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'}