Lazy hydration (Islands)
It’s not stricly speaking “Islands”, as you’re mostly using native platform capabilities.
However those patterns share the same goal: loading JavaScript and hydrate the
DOM in a lazy fashion.
Loading when the browser is idle
As an illustration, the very docs website you are visiting right now has a search bar which is made of numerous JS-heavy modules.
There is the Pagefind engine UI, Pagefind index chunks (with WASM), the Shoelace dialog component, the <search-popup>
custom element itself, the CSS…
All this doesn’t need to be loaded eagerly, this is why you can defer these tasks when everything else is settled.
Hopefully, you got the native requestIdleCallback
global function just for accomplishing this!
Combined with lazy ES Module (import(…)
), Vite / Rollup smart bundling, Custom Elements registry, and Lit’s auto-hydration, you’ll get a full fledged “Island” pattern to work with.
First we got this template partial. When loading this payload in the browser, everything is static (for now):
📄 /src/modules/site-search.ts
import { htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>
Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html
tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
} from 'lit';
export function siteSearchfunction siteSearch(): TemplateResult<1>
() {
return htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>
Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html
tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
`
<div class="m-site-search">
<!-- v—— Not loaded yet! It is just a generic element for now -->
<search-popup>
<button>Search</button>
</search-popup>
</div>
`;
}
Our <search-popup>
component can look like this:
📄 /src/features/search-popup.ts
import { LitElementclass LitElement
Base element class that manages element properties and attributes, and
renders a lit-html template.
To define a component, subclass LitElement
and implement a
render
method to provide the component's template. Define properties
using the
{@linkcode
LitElement.properties
properties
}
property or the
{@linkcode
property
}
decorator.
, htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>
Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html
tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
} from 'lit';
import { customElementconst customElement: (tagName: string) => CustomElementDecorator
Class decorator factory that defines the decorated class as a custom element.
} from 'lit/decorators.js';
import './my-dialog.js';
// ...
This will be loaded lazily alongside the web component.
await import('./my-HEAVY-stuff.js');
@customElementfunction customElement(tagName: string): CustomElementDecorator
Class decorator factory that defines the decorated class as a custom element.
('search-popup')
export class SearchPopupclass SearchPopup
extends LitElementclass LitElement
Base element class that manages element properties and attributes, and
renders a lit-html template.
To define a component, subclass LitElement
and implement a
render
method to provide the component's template. Define properties
using the
{@linkcode
LitElement.properties
properties
}
property or the
{@linkcode
property
}
decorator.
{
renderSearchPopup.render(): TemplateResult<1>
Invoked on each update to perform rendering tasks. This method may return
any value renderable by lit-html's ChildPart
- typically a
TemplateResult
. Setting properties inside this method will not trigger
the element to update.
() {
return htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>
Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html
tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
`
<!-- The button in light DOM will be slotted here -->
<slot></slot>
<my-dialog>
<!-- ... -->
</my-dialog>
`;
}
}
And now, the magic trick is:
📄 /src/modules/site-search.client.ts
requestIdleCallbackfunction requestIdleCallback(callback: IdleRequestCallback, options?: IdleRequestOptions): number
(() => import('../components/search-popup.js'));
That’s all you need for deferring non-visible components when your user browser has finished doing critical work; after the page has loaded.
Note that for everything visible right away, you should avoid this technique
or it will cause a flash of unstyled content (FOUC).
Important
You might need a polyfill for requestIdleCallback
!
As often, Gracile avoids shipping implicit features like these.
Load on user interaction
Do the same as the first example above, but instead of using requestIdleCallback
in
the client script, you could do something like:
📄 /src/modules/site-search.client.ts
It will be imported once in the module graph, anyway, so this check is optional.
let searchPopupLoadedlet searchPopupLoaded: boolean
= false;
documentvar document: Document
.querySelectorParentNode.querySelector<Element>(selectors: string): Element | null (+4 overloads)
Returns the first element that is a descendant of node that matches selectors.
('search-popup').addEventListenerElement.addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void (+1 overload)
Appends an event listener for events whose type attribute value is type. The callback argument sets the callback that will be invoked when the event is dispatched.
The options argument sets listener-specific options. For compatibility this can be a boolean, in which case the method behaves exactly as if the value was specified as options's capture.
When set to true, options's capture prevents callback from being invoked when the event's eventPhase attribute value is BUBBLING_PHASE. When false (or not present), callback will not be invoked when event's eventPhase attribute value is CAPTURING_PHASE. Either way, callback will be invoked if event's eventPhase attribute value is AT_TARGET.
When set to true, options's passive indicates that the callback will not cancel the event by invoking preventDefault(). This is used to enable performance optimizations described in § 2.8 Observing event listeners.
When set to true, options's once indicates that the callback will only be invoked once after which the event listener will be removed.
If an AbortSignal is passed for options's signal, then the event listener will be removed when signal is aborted.
The event listener is appended to target's event listener list and is not appended if it has the same type, callback, and capture.
('mouseenter', () => {
if (searchPopupLoadedlet searchPopupLoaded: boolean
) return;
import('../components/search-popup.js');
searchPopupLoadedlet searchPopupLoaded: boolean
= true;
});
You have total control on how you want to selectively “hydrate”.
You can
use any kind of interaction, with IntersectionObserver
, when clicking specific elements, etc.
You could also generalize you preferred approaches with a declarative pattern, like adding data-load-on="idle|hover|visible|..."
on the elements to target.