Progressive interactivity
There are up to 6 layers to choose from when it’s time to augment your web project with JavaScript. From pure static HTML, to a full-blown web-app experience, with hydration and client-side routing.
- Full static
Pre-rendered HTML pages, without JS at all. - Semi static
Surgically inserted bits of JS, for basic interactions. - Progressively enhanced
”Invisible” Custom HTML elements, loaded smartly. - Islands
Custom (Lit) HTML elements, loaded smartly and hydrated. - Whole-page hydration
The route root body template is interactive. - Client-side routing
On route change, the page is re-rendered with its JS template and new properties.
5 and 6 are a bit special. They load the same route modules on the client and the server.
By being holistic, they require to embrace a different mental model.
In this guide, we will explore each of these layers and see how they can be interleaved.
Layers
1. Full static
Just make sure to use the Lit SSR server html
, which is a bit more performant, and doesn’t leave hydration markers in your rendered HTML markup (<-- lit-… -->
).
It’s re-exported from @gracile/gracile/server-html
.
You really have nothing special to do beside this precaution.
Gracile is no-JS by default.
2. Semi static
You can add inline <script>
s within your template, or inside your main script entry point.
See the Defining base document page.
From there, you can use the numerous DOM APIs to target specific static markup parts and make them alive.
Beware that this is suited for content oriented website, where interactivity is kept to basic endeavors, like color mode toggle, bare HTML forms…
3. Progressively enhanced
Sometimes, you need interactivity for things that will not be visible to the user until some action is done.
Things like tooltips, color mode toggles, context menus, search bars, forms…
This means that code will just have to run on the browser and never on the server.
That also means you still don’t need any hydration mechanism at this point - so that is fewer KBs over the wire.
This is a perfect scenario for Custom Elements, where you will “augment” an existing part of markup, with or without Shadow DOM.
This pattern is almost an Island like pattern, but with less complexity, because we skip the whole server story here.
Note that, like the “Island” pattern just below, you are free to load the component eagerly, lazily, or in a user action-aware fashion.
4. Islands
Make sure to load this client-script, as eagerly as possible:
import '@gracile/gracile/hydration-elements';
This will bootstrap the hydration mechanisms for Lit Custom Elements, as soon as they are defined.
TODO
Hydration is needed because on the first request, user will get the Custom Element as a pre-rendered HTML chunk, alongside its hydration markers, housed in the Declarative Shadow DOM.
5. Whole-page hydration
Caution
Experimental. This is not well tested, nor it is customizable.
Sometimes, you need more than isolated Islands of interactivity.
For example, you may want to attach event handlers to your templates in the Light DOM.
For this task, make sure to load this client-script, as eagerly as possible this hydration bootstrapper:
📄 ./src/document.client.ts
import '@gracile/gracile/hydration-full';
It will load the route module in the browser, and hydrate the SSRed markup.
When changing a route, by clicking a link, or navigating the history, your website will behave as any “MPA”; the whole page context will be blown away.
Important
You need to expose the page premises for the whole page hydration strategy to work.
6. Client-side routing
Caution
Experimental. This is not well tested, nor it is customizable enough.
If you want to persist your page state between routes, you will need to resort to a client-side router.
Luckily, Gracile has an add-on for that, which is a fork of thepassle/app-tools router.
It intercepts links clicking and history navigation, pick up the associated page premises (properties and templates), then the Lit client renderer do its work.
Since the markup is already there on first page load, Lit not render on the client, it will just hydrate the page body and custom elements.
This SPA router is still in an early phase, but it already supports quite a lot.
Important
Similarly to the whole-page hydration, you need to expose the page premises for client side routing to work.
See the full documentation for the client side router.
Guidance
Expose the page premises
To activate page premises, make sure to configure Gracile priorly, with the pages.premises.expose
flag on.
Gracile comes with the concept of “page premises”.
By default, the Gracile handler will expose your routes like this:
/src/routes/(home).ts
→/
/index.html
/src/routes/about.ts
→/about/
/about/index.html
- …
With page premises, each route will have its properties and document exposed:
/src/routes/(home).ts
→/
/index.html
/__index.doc.html
/__index.props.json
/src/routes/about.ts
→/about/
/about/index.html
/about/__index.doc.html
/about/__index.props.json
- …
You don’t really need to be aware of this, as Gracile will consume those endpoints under the hood for you. But it’s important to acknowledge that, to not be surprised.
It’s a useful primitive that expose what your routes are made of, but at a more granular level, before it is fully rendered to HTML.
Its main application is providing a hook for client side operations, where you need pure data, or the minimal HTML required for diffing the <head>
scripts and styles.
Gracile is using this hook for its 5th and 6th interactivity strategies.
If you don’t need this level of interactivity, YOU SHOULD KEEP THIS OPTION DISABLED, to avoid superfluous endpoints and/or built artifacts.
Page premises are live endpoint when a route is dynamic.
When a specific route is pre-rendered (in server mode) or the site is fully pre-rendered (SSG), it will put the premises alongside the route main indexes.
Hydration and loading strategies
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, the 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<"search-popup">(selectors: "search-popup"): SearchPopup | null (+4 overloads)
Returns the first element that is a descendant of node that matches selectors.
('search-popup').addEventListenerHTMLElement.addEventListener<"mouseenter">(type: "mouseenter", listener: (this: HTMLElement, ev: MouseEvent) => any, 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.
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.
IMPORTANT: client/server code execution
When using the 5th or 6th interactivity strategy, Gracile will load your defined routes in your client bundle, for the hydration/rendering process to work.
You need to acknowledge that ALL CODE WRITTEN ON THE SERVER WILL BE ACCESSIBLE IN THE CLIENT SIDE.
You will not be required to use a sibling my-route.client.ts
file for loading client-side only script anymore, but that remain a very useful option, if you want to make a clear distinction between client/server realms.
Otherwise, you can use a conditional, like the isServer
flag offered by lit
, that will make sure that in a Server based environment, the code will be tree-shaken - effectively removed from the client bundle.
import { isServer } from 'lit';
if (!isServer) requestIdleCallback(() => init());
This flag is based one the node
export condition.
You can also use the Gracile nodeCondition
helper for that.