Picking up where Convenient Usage left off - and going straight to some of the hardest things, so you know they're there when you need them.
Take full control over your rendering cycle.
Most components only build - describe what they show, in other components. A render component does the rendering itself, straight onto the DOM, any way it likes: this gauge writes its own SVG, as a template literal. What Cascade provides is the rest: a well defined render order between all of your components, and proper invalidation and revalidation - the gauge is drawn again exactly when something it read has changed, and only then. How it draws is up to you.
import { Component, callback } from "@liquefy/cascade.component";
import { DOMNodeComponent, input } from "@liquefy/cascade.dom";
import { row, themeColor } from "@liquefy/cascade.ui";
// A render component: it makes its own DOM - here an SVG gauge, written as
// a template literal - and keeps it up to date. Cascade decides when:
// whenever something it read changes (this.value).
export class Gauge extends DOMNodeComponent {
setProperties({ value }) {
this.value = value; // 0 - 100
}
ensureNode() {
const u = this.unobservable;
if (!u.node) u.node = document.createElement("div");
u.node.innerHTML = gaugeSvg(this.value);
return u.node;
}
}
// The theme's colors, as CSS variables - in style: an SVG attribute
// doesn't take var().
export function gaugeSvg(value, color = themeColor.accent) {
const arc = "M 10 60 A 50 50 0 0 1 110 60";
return `
<svg viewBox="0 0 120 70" width="240" height="140" role="img" aria-label="${value}%">
<path d="${arc}" fill="none" style="stroke: ${themeColor.border}" stroke-width="12" stroke-linecap="round" />
<path d="${arc}" fill="none" style="stroke: ${color}" stroke-width="12" stroke-linecap="round"
pathLength="100" stroke-dasharray="${value} 100" />
<text x="60" y="58" text-anchor="middle" font-size="18" font-weight="bold" fill="currentColor">${value}%</text>
</svg>`;
}
// Using it - like any other component.
export class GaugeDemo extends Component {
initialState() {
return { value: 64 };
}
build() {
return row(
{ style: { alignItems: "center", gap: "24px", flexWrap: "wrap" } },
new Gauge({ value: this.value }),
input({
type: "range",
min: 0,
max: 100,
value: this.value,
oninput: callback("slide", (event) => { this.value = Number(event.target.value); }),
}),
);
}
}
Then implement render() itself. A component that renders isn't given a node - it's given a time: its turn in the rendering of the DOM, after what renders before it and before what renders after. What it puts there is up to it - one node, many, or none at all. It renders onto its target - the element its parent renders into - right after target.lastChild, the node rendered just before it, and then advances lastChild to the last of its own, or leaves it where it was if it has none. These thermometers are as many nodes as the count says, side by side in the row, with no wrapper around them - and at zero, nothing at all: "Before" and "After" meet.
Here is where temporal signals show their magic. lastChild is one property, written by every component in the row in turn - yet each one reads it as the component just before it left it. So "After" lands after the last thermometer, without knowing how many there are, or if there are any. Change the count, and lastChild changes for what comes after: the thermometers render again, and so does "After" - it read lastChild - but "Before" doesn't. Move the slider, and lastChild stays the same: only the thermometers render again.
Note also that rendering occurs in real time against the live DOM, so if you need to take measures, prepare animations, do and undo things against the DOM, this is the place to do it.
import { Component, callback } from "@liquefy/cascade.component";
import { div, input, text } from "@liquefy/cascade.dom";
import { button, card, row, column, themeColor } from "@liquefy/cascade.ui";
// As many nodes as `count` says - zero, one or many, side by side, with no
// wrapper around them. So it renders itself: render(target) puts them
// straight on the target, the element its parent renders into, right after
// target.lastChild - and then advances lastChild to the last of them, or,
// with none, leaves it where it was.
//
// lastChild is a temporal signal: each component reads it as the one
// rendered just before it left it. So whatever comes after the thermometers
// sees the last of them - or, with none, whatever came before them - and
// lands right after it. (Read it once and write it once: a component
// reading back what it wrote itself would only be rendered again.)
export class Thermometers extends Component {
setProperties({ count, temperature }) {
this.count = count;
this.temperature = temperature;
}
render(target) {
const u = this.unobservable;
if (!u.nodes) u.nodes = [];
while (u.nodes.length < this.count) u.nodes.push(document.createElement("div"));
while (u.nodes.length > this.count) u.nodes.pop().remove();
for (const node of u.nodes) node.innerHTML = thermometerSvg(this.temperature);
this.place(target);
}
place(target) {
let previous = target.lastChild; // what was rendered just before
for (const node of this.unobservable.nodes) {
const next = previous ? previous.nextSibling : target.element.firstChild;
// Touch the real DOM only when it's not already in place.
if (next !== node) target.element.insertBefore(node, next);
previous = node;
}
target.lastChild = previous; // what's rendered next goes after them all
}
// Not rendered for a while (see DOMNodeComponent, which does the
// same for one node): take the nodes out - and put them back, in place.
onRetract() {
for (const node of this.unobservable.nodes || []) node.remove();
super.onRetract();
}
onReattach(target) {
if (this.unobservable.nodes) this.place(target);
super.onReattach(target);
}
}
// -20 - 40 degrees.
export function thermometerSvg(temperature) {
const level = (temperature + 20) / 60; // 0 - 1
const top = 14 + (1 - level) * 96;
return `
<svg viewBox="0 0 40 160" width="40" height="160" role="img" aria-label="${temperature}°">
<rect x="13" y="8" width="14" height="112" rx="7" style="fill: ${themeColor.border}" />
<circle cx="20" cy="124" r="14" fill="#e74c3c" />
<rect x="17" y="${top}" width="6" height="${124 - top}" rx="3" fill="#e74c3c" />
<text x="20" y="156" text-anchor="middle" font-size="12" font-weight="bold" fill="currentColor">${temperature}°</text>
</svg>`;
}
// Using it - between two cards, all in one row. The count is changed from
// outside: at zero, there's nothing left of the thermometers to hold it.
export class ThermometersDemo extends Component {
initialState() {
return { count: 3, temperature: 21 };
}
build() {
return column(
{ style: { gap: "16px" } },
row(
// As tall as a thermometer, with or without any: the controls
// below stay put.
{ style: { alignItems: "center", gap: "16px", flexWrap: "wrap", minHeight: "200px" } },
card(text("Before")),
new Thermometers({ count: this.count, temperature: this.temperature }),
card(text("After")),
),
row(
{ style: { alignItems: "center", gap: "16px", flexWrap: "wrap" } },
button(text("−"), callback("fewer", () => { this.count = Math.max(0, this.count - 1); })),
div(text(this.count + (this.count === 1 ? " thermometer" : " thermometers"))),
button(text("+"), callback("more", () => { this.count = Math.min(8, this.count + 1); })),
input({
type: "range", min: -20, max: 40, value: this.temperature,
oninput: callback("temperature", (event) => { this.temperature = Number(event.target.value); }),
}),
),
);
}
}
A render target is where components render - and it can be anything: a subclass of your own, with a state of its own. Here, a paper that words lay themselves out on, in lines - the words laid out so far kept in an array, that each word reads the last of, and pushes itself onto. A temporal array: each word sees it as the words before it left it. Change a word, and only it and the words after it lay out again - and only as far as something really moved: a word reads nothing but the last one, so once a word lands where it was, the rest stay put. Not a DOM element anywhere - it runs just as well in Node.
The same way, a render target could wrap another native UI framework, and have Cascade components render onto its widgets.
import { Component, observable, frozen } from "@liquefy/cascade.component";
// A render target of your own - no DOM anywhere: a paper, holding the words
// laid out on it, in order. A temporal array: each word sees it as the
// words before it left it.
class Paper {
constructor(width) {
this.width = width;
this.words = observable([]);
return observable(this);
}
}
// A word, laying itself out on the paper: right after the last word placed
// before it - on the next line, if it doesn't fit.
class Word extends Component {
setProperties({ text }) {
this.text = text;
}
render(paper) {
// Only the last word: it's laid out again only if that one moved or
// changed - not for anything else on the paper.
const previous = paper.words.at(-1);
let line = previous ? previous.line : 1;
let column = previous ? previous.column + previous.text.length + 1 : 0;
if (column > 0 && column + this.text.length > paper.width) {
line = line + 1;
column = 0;
}
console.log(` "${this.text}" - line ${line}, column ${column}`);
// Frozen: a value, compared by content - placed the same as before,
// and the word after it doesn't lay out again.
paper.words.push(frozen({ text: this.text, line, column }));
}
}
class Sentence extends Component {
setProperties({ words }) {
this.words = words;
}
build() {
return this.words.words.map((text, index) => new Word({ key: "word" + index, text }));
}
}
const words = observable({ words: ["Temporal", "signals", "lay", "out", "words", "on", "paper"] });
const paper = new Paper(20);
new Sentence({ words }).renderOnto(paper);
// "Temporal" - line 1, column 0
// "signals" - line 1, column 9
// "lay" - line 1, column 17
// "out" - line 2, column 0
// "words" - line 2, column 4
// "on" - line 2, column 10
// "paper" - line 2, column 13
// A longer third word: only it, and the words after it, lay out again -
// "Temporal" and "signals" are where they were.
words.words = ["Temporal", "signals", "gracefully", "out", "words", "on", "paper"];
// "gracefully" - line 2, column 0
// "out" - line 2, column 11
// "words" - line 2, column 15
// "on" - line 3, column 0
// "paper" - line 3, column 3
// A word of the same length: it lays out again, and so does the next -
// which lands where it was, so the rest stay put.
words.words = ["Temporal", "signals", "gracefully", "put", "words", "on", "paper"];
// "put" - line 2, column 11
// "words" - line 2, column 15
// Read from outside, the paper holds all of them - as the last word left it.
console.log(paper.words.map((word) => word.text).join(" "));
// Temporal signals gracefully put words on paper
Taken further, this is cascade.print: a document of paragraphs and styles, laid out onto papers in micrometers - each paragraph broken into lines, then placed page by page - shown, edited at a caret, and printed.
Endless configuration of an existing application.
Everything a component builds with - every HTML element, every themed widget, every text - it asks for, through the service locator in its render context. So a part of an app can be given services of its own, without changing a line of it. Here, an existing component - twice: as it is, and inside a service provider that hands out every text in Swedish. The same idea styles, instruments or replaces anything, anywhere.
Save your changes?
Spara dina ändringar?
import { Component, serviceProvider } from "@liquefy/cascade.component";
import { DOMServiceLocator, p, text } from "@liquefy/cascade.dom";
import { button, card, row } from "@liquefy/cascade.ui";
// A component of an existing app - it knows nothing about languages.
class SaveChanges extends Component {
build() {
return card(
{ style: { display: "flex", flexDirection: "column", gap: "12px" } },
p({ style: { margin: 0 } }, text("Save your changes?")),
row({ style: { gap: "8px" } }, button(text("Save")), button(text("Discard"))),
);
}
}
// A service provider of your own: every text the app asks for, in Swedish -
// and everything else, whatever the app already had.
const swedish = { "Save your changes?": "Spara dina ändringar?", "Save": "Spara", "Discard": "Släng" };
const dom = new DOMServiceLocator();
const inSwedish = {
locate(query) {
if (query.type !== "textNode") return undefined;
const { text: original, ...rest } = query.properties;
return dom.locate({ ...query, properties: { ...rest, text: swedish[original] ?? original } });
},
};
// The same component, twice: as it is - and in Swedish.
export class TranslationDemo extends Component {
build() {
return row(
{ style: { gap: "16px", flexWrap: "wrap", overflow: "visible" } },
new SaveChanges(),
serviceProvider({ serviceLocator: inSwedish, child: new SaveChanges() }),
);
}
}
A component can lay itself out by the room it really has, measured: elementBoundsProvider() measures its own element, and hands the size to its child. Drag the frame's corner - no media queries, and not the window's size: the component's own.
import { Component } from "@liquefy/cascade.component";
import { div, text, elementBoundsProvider } from "@liquefy/cascade.dom";
import { icon, themeColor } from "@liquefy/cascade.ui";
// Lays itself out by the room it really has - measured, not guessed: side
// by side when there's room, stacked when there isn't.
class Profile extends Component {
build() {
const width = this.fromTarget("width") || 0; // the element it's placed in, measured
const wide = width >= 420;
return div(
{
style: {
display: "flex", flexDirection: wide ? "row" : "column", alignItems: "center", gap: "16px",
padding: "16px", textAlign: wide ? "left" : "center",
},
},
icon({ name: "account_circle", style: { fontSize: wide ? "64px" : "48px" } }),
div(
div({ style: { fontWeight: "bold", fontSize: "18px" } }, text("Ada Lovelace")),
div({ style: { opacity: 0.7 } }, text((wide ? "Wide: " : "Narrow: ") + Math.round(width) + "px to lay out in")),
),
);
}
}
// The provider measures its own element - here one you can resize: drag
// its corner - and hands the size to its child, as
// what's placed on it: this.fromTarget("width") and this.fromTarget("height").
// Its height follows what's inside it, so only its width is contained.
export class Measured extends Component {
build() {
return elementBoundsProvider({
style: {
width: "100%", maxWidth: "100%", minWidth: "220px", boxSizing: "border-box", contain: "inline-size layout",
resize: "horizontal", overflow: "auto", border: "1px solid " + themeColor.border, borderRadius: "8px",
},
child: new Profile(),
});
}
}
Pages that can be read without running them.
A single page app is an empty page until its JavaScript has run - and a search engine, a link preview or an AI fetching a product's address often doesn't run it. cascade.prerender opens every page of the built app in a real browser, at build time, and writes down what it shows as plain HTML: one file per address, with the page's title, description and structured data in its head, and a sitemap. When the app's code has loaded, it takes over in a single frame. This demo is prerendered too - open any page's source.
A real browser, because Cascade lays out from real measurements: without a layout engine, there would be nothing true to write down.
// vite.config.js
import { defineConfig } from "vite";
import { cascadePrerender } from "@liquefy/cascade.prerender";
export default defineConfig({
plugins: [
cascadePrerender({
site: "https://shop.example",
// Where to start - every page they link to is found too.
routes: async () => ["", ...(await fetchProductIds()).map((id) => "product/" + id)],
exclude: [/^checkout/, /^account/],
}),
],
});In the app, the root is rendered onto a PrerenderedElementTarget, and documentHead() builds the head from the app's data - prices and availability shown right in search results:
// index.js
import { browserLocation, documentHead } from "@liquefy/cascade.dom";
import { PrerenderedElementTarget } from "@liquefy/cascade.prerender/client";
const location = browserLocation({ base: import.meta.env.BASE_URL });
// The root: a prerendered page's snapshot gives way to the live app, in one frame.
const target = PrerenderedElementTarget.forElement(document.getElementById("application"));
shop.renderOnto(target, context);
// The head, built from the shop's data - and following it as the user navigates.
documentHead(() => {
const product = catalogue.productAt(location.path);
if (!product) return { title: "Shop" };
return {
title: product.name + " - Shop",
description: product.summary,
canonical: location.href(location.path),
image: product.photo,
type: "product",
structuredData: {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
offers: { "@type": "Offer", price: product.price, priceCurrency: "EUR" },
},
};
});A word of warning: prerendering and programmatic responsive layout. A page is prerendered at one size - a desktop's. Layout decided from measurements, as above, is decided in JavaScript: until the app has loaded, a phone shows the desktop layout, squeezed - and then the app lays it out again, with a visible jolt. Try this demo's menu on a phone. What must be right from the very first paint is best left to CSS: media queries, flex wrapping, relative sizes - the browser lays them out before any JavaScript has run. Keep measuring for what can wait a moment.
Switching pages in Cascade is lightning fast, because nothing is rebuilt: a page switched away from keeps its whole tree of DOM nodes, waiting off screen, with its state - and switching back just puts it back in place. But that only works for children that stay alive. A keyed child that its parent's build() doesn't construct, even once, is gone for good: the next time it's built, it's a new one - new state, new DOM.
Count up in each, switch tab, and switch back - the first forgets, the other two remember:
Create your children in build(), with a key - but don't hide them behind an if. Build them every time, and leave them out with .showIf(condition) instead: a child built but not shown is only hidden, while its key keeps it alive.
Create them yourself, once, in initialUnobservables() - and since no build does it for you, call establish() on them there, and dispose() in your own onDispose(). Skip the dispose, and a child reading data that never changes again is never invalidated - it holds on to all it built and subscribed to, for good. Unobservables, because nothing needs to observe the references - an observable property for them would only be overhead. Then build() just places them, where and when it likes.
import { Component, callback } from "@liquefy/cascade.component";
import { div, text } from "@liquefy/cascade.dom";
import { button, card, column, row } from "@liquefy/cascade.ui";
// A child with state of its own - to see whether it survives.
class Tally extends Component {
setProperties({ label }) {
this.label = label;
}
initialState() {
return { count: 0 };
}
build() {
return button(text(this.label + ": " + this.count), callback("increment", () => { this.count++; }));
}
}
// Two tabs, and the tab bar to switch between them.
class Tabs extends Component {
initialState() {
return { tab: "first" };
}
tabBar() {
const tab = (key, label) => button(
{ variant: this.tab === key ? "filled" : undefined },
text(label),
callback(key + "Tab", () => { this.tab = key; }),
);
return row({ style: { gap: "8px" } }, tab("first", "First"), tab("second", "Second"));
}
}
// Don't: the tab not shown isn't built at all - its key drops out of the
// build, and it's gone for good. Switch back, and it starts over from 0.
export class Guarded extends Tabs {
build() {
return column(
{ style: { gap: "12px" } },
this.tabBar(),
this.tab === "first"
? new Tally({ key: "first", label: "First" })
: new Tally({ key: "second", label: "Second" }),
);
}
}
// 1. Built in every build, with a key - and shown or not with .showIf().
export class Shown extends Tabs {
build() {
return column(
{ style: { gap: "12px" } },
this.tabBar(),
new Tally({ key: "first", label: "First" }).showIf(this.tab === "first"),
new Tally({ key: "second", label: "Second" }).showIf(this.tab === "second"),
);
}
}
// 2. Full control: created once, in initialization - and established
// there, and disposed when this is, since no build() does either for them
// (see Component.establish()). Kept as unobservables - nothing needs to observe the references, so
// an observable property for them would only be overhead.
export class Owned extends Tabs {
initialUnobservables() {
return {
first: new Tally({ key: "first", label: "First" }).establish(),
second: new Tally({ key: "second", label: "Second" }).establish(),
};
}
onDispose() {
this.unobservable.first.dispose();
this.unobservable.second.dispose();
super.onDispose();
}
build() {
return column(
{ style: { gap: "12px" } },
this.tabBar(),
this.tab === "first" ? this.unobservable.first : this.unobservable.second,
);
}
}
// All three, side by side: count up, switch tab, and switch back.
export class KeepAlive extends Component {
build() {
const variant = (title, child) => card(
{ style: { display: "flex", flexDirection: "column", gap: "12px", flex: "1 1 180px", boxSizing: "border-box" } },
div({ style: { fontWeight: "bold" } }, text(title)),
child,
);
return row(
{ style: { gap: "16px", flexWrap: "wrap", alignItems: "stretch" } },
variant("if - forgets", new Guarded()),
variant("1. .showIf() - remembers", new Shown()),
variant("2. Owned - remembers", new Owned()),
);
}
}
Create them where no build ever sees them being created - in a module, or anywhere else outside of any component - and hand them in as properties. That's how this very demo keeps its pages. Establish them where you create them; nothing disposes them, so it's for what lives as long as the app does.
// Created once, outside of any build - established here, and handed to the frame.
const applicationMenuFrame = new ApplicationMenuFrame({
rootServiceLocator,
location,
pages: [
{ key: "introduction", title: "Introduction", component: new IntroductionPage().establish() },
{ key: "getting-started", title: "Getting Started", component: new GettingStartedPage().establish() },
// ...
],
}).establish();A word of warning: never combine these methods. A child constructed inside build() belongs to that build, even if you keep a reference to it as well: the first build that doesn't construct it disposes it - out from under your reference.