Web component HTML rendering that includes:
- Rendering to Declarative Shadow DOM, requiring no JavaScript in the client.
- Automatic inclusion of the Declarative Shadow DOM polyfill for browsers without support.
- Streaming HTML responses.
- Compatibility with the most popular web component libraries (see a compatibility list below).
- Lazy partial hydration via special attributes: hydrate on page load, CPU idle, element visibility, or media queries. Or create your own hydrator.
Table of Contents
An ocean is an environment for rendering web component code. It provides an html
function that looks like the ones you're used to from libraries like uhtml and Lit. Instead of creating reactive DOM in the client like those libraries, Ocean's html
returns an async iterator that will stream out HTML strings.
Ocean is somewhat low-level and is meant to be used with a higher-level framework. Typical usage looks like this:
import 'https://cdn.spooky.click/ocean/1.3.1/shim.js?global';
import { Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';
const { HTMLElement, customElements, document } = globalThis;
class AppRoot extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
let div = document.createElement('div');
div.textContent = `This is an app!`;
this.shadowRoot.append(div);
}
}
customElements.define('app-root', AppRoot);
const { html } = new Ocean({
document,
polyfillURL: '/webcomponents/declarative-shadow-dom.js'
});
let iterator = html`
<!doctype html>
<html lang="en">
<title>My app</title>
<app-root></app-root>
`;
let code = '';
for await(let chunk of iterator) {
code += chunk;
}
console.log(chunk); // HTML string
The above will generate the following HTML:
<!doctype html>
<html lang="en">
<title>My app</title>
<script type="module">const o=(new DOMParser).parseFromString('<p><template shadowroot="open"></template></p>',"text/html",{includeShadowRoots:!0}).querySelector("p");o&&o.shadowRoot||async function(){const{hydrateShadowRoots:o}=await import("/webcomponents/declarative-shadow-dom.js");o(document.body)}()</script>
<app-root>
<template shadowroot="open">
<div>This is an app!</div>
</template>
</app-root>
Ocean comes with its main module and a DOM shim for compatible with custom element code.
The main module for Ocean is available in two forms: bundled and unbundled.
- If you are using Ocean in a browser context, such as a service worker, use the bundled version.
- If you are using Ocean in Deno, use the unbundled version.
import { Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';
import { Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.bundle.js';
Ocean's DOM shim is backed by linkedom, a fast DOM layer. The shim also bridges compatibility with popular web component libraries.
It's important to import the DOM shim as one of the first imports in your app.
import 'https://cdn.spooky.click/ocean/1.3.1/shim.js?global';
Notice that this includes in the ?global
query parameter. This makes the shim available on globals; you get document
, customElements
, and other commonly used global variables.
If you do not want to shim the global environment you can omit the ?global
query parameter and instead get the globals yourself from the symbol Symbol.for('dom-shim.defaultView')
. This is advanced usage.
import 'https://cdn.spooky.click/ocean/1.3.1/shim.js';
const root = globalThis[Symbol.for('dom-shim.defaultView')];
const { HTMLElement, customElements, document } = root;
Partial hydration is the practice of only hydrating (via running client JavaScript) components that are needed for interactivity. Ocean does not automatically add scripts for components by default. However Ocean does support both full and partial hydration. This means you can omit the component script tags from your HTML and Ocean will automatically add them for you.
In order to add script tags you have to provide Ocean a map of tag names to URLs to load. You do this through the elements
Map that is returned from the constructor.
let { html, elements } = new Ocean({
document
});
elements.set('app-sidebar', '/elements/app-sidebar.js');
Note: Ocean only adds script tags for elements that are server rendered. If you are not server rendering an element you will need to add the appropriate script tags yourself.
Full hydration means added script tags to the <head>
for any components that are server rendered. You can enable full hydration by passing this in the constructor:
let { html, elements } = new Ocean({
document,
hydration: 'full'
});
elements.set('app-sidebar', '/elements/app-sidebar.js');
customElements.define('app-sidebar', class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
let div = document.createElement('div');
div.textContent = `My sidebar...`;
this.shadowRoot.append(div);
}
});
Then when you render this element, it will include the script tags:
let iterator = html`
<!doctype html>
<html lang="en">
<title>My app</title>
<app-sidebar></app-sidebar>
`;
let out = '';
for(let chunk of iterator) {
out += chunk;
}
Will produce this HTML:
<!doctype html>
<html lang="en">
<title>My app</title>
<script type="module" src="/elements/app-sidebar.js"></script>
<app-sidebar>
<template shadowroot="open">
<div>My sidebar...</div>
</template>
</app-sidebar>
By default Ocean uses partial hydration. In partial hydration script tags are only added when you explicitly tell Ocean to hydration an element. This means that by default elements will be rendered to HTML only, and never iteractive on the client.
This allows you to use the web component libraries you love both to produce static HTML and for interactive content.
To declare an element to be hydrated, use the ocean-hydrate
attribute on any element. The value should be one of:
- load: Hydrate when the page loads. Ocean will add a
<script type="module">
tag for the element's script. - idle: Hydrate when the CPU becomes idle. Ocean will add an inline script that waits for
requestIdleCallback
and then loads the element's script. - media: Hydrates on a matching media query. This allows you to have some elements which only hydrate for certain screen sizes. Use the
ocean-query
attribute to specify the media query. - visible: Hydrate when the element becomes visible. This is useful for elements which are shown further down the page. Ocean will add an inline script that uses Intersection Observer to determine when the element is visible and then loads the script.
Using one of these hydrators looks like:
let iterator = html`
<!doctype html>
<html lang="en">
<title>My site</title>
<app-sidebar ocean-hydrate="idle"></app-sidebar>
`;
You can specify which hydrators you want to use by providing the hydrators
option to Ocean. Each of the default hydrators are included by default, but can also be imported.
import {
HydrateIdle,
HydrateLoad,
HydrateMedia,
HydrateVisible,
Ocean
} from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';
To specify to hydrate on load, pass load
into the ocean-hydrate
attr:
let { html } = new Ocean({ document });
let iterator = html`
<!doctype html>
<html lang="en">
<title>My site</title>
<app-sidebar ocean-hydrate="load"></app-sidebar>
`;
HydrateLoad does not take any options because it only adds a script tag to the head. You can create an instance by calling new
on it:
import { HydrateLoad, Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';
let { html } = new Ocean({
document,
hydrators: [
new HydrateLoad()
]
});
To specify to hydrate on idle, pass idle
into the ocean-hydrate
attr:
let { html } = new Ocean({ document });
let iterator = html`
<!doctype html>
<html lang="en">
<title>My site</title>
<app-sidebar ocean-hydrate="idle"></app-sidebar>
`;
HydrateIdle uses a custom element to perform hydration when the CPU is idle. By default that custom element name is ocean-hydrate-idle
. You can specify a different custom element name by passing it into the constructor.
import { HydrateIdle, Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';
let { html } = new Ocean({
document,
hydrators: [
new HydrateIdle('my-app-hydrate-idle')
]
});
To hydrate on a media query, pass media
into the ocean-hydrate
attr, and also provide a ocean-query
attr with the media query to use:
let { html } = new Ocean({ document });
let iterator = html`
<!doctype html>
<html lang="en">
<title>My site</title>
<app-sidebar ocean-hydrate="media" ocean-query="(max-width: 700px)"></app-sidebar>
`
HydrateMedia uses the custom element ocean-hydrate-media
to hydrate your custom element. You can customize this, and also the attribute used for the query by passing those arguments into the constructor:
import { HydrateMedia, Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';
let { html } = new Ocean({
document,
hydrators: [
new HydrateMedia('my-app-hydrate-media', 'app-query')
]
});
let iterator = html`
<!doctype html>
<html lang="en">
<title>My site</title>
<app-sidebar ocean-hydrate="media" app-query="(max-width: 700px)"></app-sidebar>
`;
To specify to hydrate on element visibility, pass visible
into the ocean-hydrate
attr:
let { html } = new Ocean({ document });
let iterator = html`
<!doctype html>
<html lang="en">
<title>My site</title>
<app-sidebar ocean-hydrate="visible"></app-sidebar>
`;
HydrateVisible uses the custom element ocean-hydrate-visible
to track when your element is visible. You can customize this custom element tag name by passing in something else into the constructor:
import { HydrateVisible, Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';
let { html } = new Ocean({
document,
hydrators: [
new HydrateVisible('my-app-hydrate-visible')
]
});
A hydrator is an object that specifies how to hydrate the element. You can create a custom hydrator and pass it to the hydrators
option.
The following is a hydrator that hydrates whenever the element is clicked.
const clickHydrator = {
condition: 'click',
tagName: 'my-click-hydrator',
renderMultiple: true,
script() {
return /* js */ `customElements.define('${this.tagName}', class extends HTMLElement {
connectedCallback() {
let el = this.previousElementSibling;
let src = this.getAttribute('src');
el.addEventListener('click', () => import(src), { once: true });
}
})`;
}
};
let { html } = new Ocean({
document,
hydrators: [
clickHydrator
]
})
Which you would use like so:
let iterator = html`
<!doctype html>
<html lang="en">
<title>My site</title>
<app-sidebar ocean-hydrate="click"></app-sidebar>
`;
The properties of a hydrator are (all required):
- condition: This is the value used with
ocean-hydrate
to trigger the hydrator to be used. - tagName: Hydrators are implemented as custom elements. The
tagName
is the custom element tag name. - renderMultiple: This says that the custom element should be rendered for each element that uses the hydrator. Use
false
when hydrating is done without regard for the element. For example idle isfalse
because it always just waits for CPU idle, so this only needs to be done once. - script(): A function which returns the custom element definition.
The following are optional properties:
- mutate(customElement, node): Gives you a change to modify the hydration custom element being rendered, for example to add information needed to perform hydration. HydrateMedia uses this method to add the query to the custom element.
When performing hydration or adding the declarative shadow DOM polyfill, Ocean adds links that you provide it. You can provide full URLs or pathnames like /js/dsd-polyfill.js
. If you'd like for these links to be relative, you can use the relativeTo
function to create an html
that will produce relative links. Here's how you might use it in a service worker context:
import 'https://cdn.spooky.click/ocean/1.3.1/shim.js?global';
import { Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';
let { relativeTo } = new Ocean({
document,
polyfillURL: '/js/dsd-polyfill.js'
})
addEventListener('fetch', event => {
let html = relativeTo(event.request.url);
let iter = html`
<!doctype html>
<html lang="en">
<!-- ... -->
`;
});
The script tags added for the polyfill and for any element hydration will be relative to the event's URL.
Ocean parses HTML into a DOM tree. Using plugins you can mutate the tree before it gets turned back into strings, allowing you to implement advanced behavior like syntax highlighting.
For the most part custom elements should be the way you customize HTML rendering; plugins are here for cases where you need to modify built-in elements.
The interface for a plugin is a function that returns an object with a handle
method. The function is called during Ocean's internal optimization step:
class MyHighlighter {
handle(node, head) {
// Mutate this node, add anything to the head that you need.
}
static createInstance() {
return new MyHighlighter();
}
}
let ocean = new Ocean({
document,
plugins: [MyHighlighter.createInstance]
});
Ocean is tested against popular web component libraries. These tests are not all inclusive, test contributions are very much welcome.
Library | Compatible | Notes |
---|---|---|
Vanilla | ✔ | |
Lit | ✔ | |
Stencil | ✔ | |
Haunted | ✔ | |
Atomico | ✔ | |
uce | ✔ | |
Preact | ✔ | |
petite-vue | ✔ | |
Wafer | ✔ | |
FAST | ✖ | Heavily relies on DOM internals. |
Lightning Web Components | ✖ | I can't figure out how to export an LWC, if you can help see #11 |