Stamps

If you've been following along from the Getting Started path, we would have most recently explored advanced binding tools such as Suspense. Stamps are a similarly advanced tool, but may be easier to pick up and explore.

Optimizing for Server-Side Rendering and/or Progressive Enhancement

Butterfloat is reasonably well-optimized for a good default DOM rendering experience. In the lifetime of a component, the static parts of the DOM are built once and only once.

However, sometimes doing things even just once may need to be optimized. If your website or app is producing lots of the same component, it can help to memoize the DOM tree creation. If you are worried about time to meaningful content paint and hoping to serve the under-privileged with any combination of slow network access, slow browsers, and limited memory, you might want to bake as much of the static DOM parts as you can into HTML for the browser's highly optimized HTML parser. There are plenty more reasons besides those to seek further "server-side rendering" or "progressive enhancement" tools.

Butterfloat's building blocks for optimizing these scenarios are called Stamps. Any Butterfloat Component that is designed to be easily unit tested should be capable of being built into a stamp. If the static DOM is predictable given the static properties passed into it, you can build a stamp of every static property variation that makes sense to optimize. With any DOM library such as JSDOM you should be able to build these stamps easily in Node or Deno (or even in a browser), either locally ahead of time or automated in a server-side render.

Stamps have markers for the interactive bindings of a Component and once a stamp has been associated with that Component with the applicable properties, the stamp can be used to instantiate a fully interactive component. Other than making sure that the Component is unit testable and the static DOM output is deterministic (given applied properties) and stable there are no other changes to the way that a Component is written. There's no concept of "server component" or "client component" to be concerned with. There's no need to mark "islands". Butterfloat already understands your bindings and will progressively enhance the stamps you give it into a working, interactive components at runtime.

Build a Stamp from a Simple Component

Given the most basic sort of component:

import { jsx } from 'butterfloat'

export function Hello() {
  return <p>Hello World</p>
}

we just need to run the component once to get its Node Descriptions and pass that to buildStamp to build a Stamp:

import { buildStamp } from 'butterfloat'
import { Hello } from './hello-component.js'

const hello = Hello()
export const helloStamp = buildStamp(hello)

?> If you are using a DOM library with its own document object, you can pass it as the second argument to buildStamp.

The Stamp output will be a <template> tag (HTMLTemplateElement) that you can serialize (such as with outerHTML) or append to some other DOM container. For instance, if you were working with an HTML template builder in JSDOM in Node, you could append it naturally:

import { JSDOM } from 'jsdom'
import { writeFile } from 'node:fs/promises'
import { helloStamp } from './hello-stamp.js'

const dom = new JSDOM(`
    <!doctype html>
    <html>
        <head>
            <title>Example Template</title>
        </head>
        <body id="app">
        </body>
    </html>
`)
const { window } = dom
const { document } = window
const appContainer = document.getElementById('app')!

// Set an id so that we can query for it
helloStamp.id = 'hello-component'
appContainer.append(helloStamp)

await writeFile('index.html', dom.serialize())

To use a Stamp you register it with a StampCollection and pass that Stamp collection to runStamps, a drop-in replacement for the normal Butterfloat run which uses a Stamp collection to reuse registered Stamps.

import { StampCollection, runStamps } from 'butterfloat'
import { Hello } from './hello-component.js'

const appContainer = document.getElementById('app')!
const helloStamp =
  appContainer.querySelector<HTMLTemplateElement>('hello-component')!

const stamps = new StampCollection()
// This component only has one Stamp
stamps.registerOnlyStamp(Hello, helloStamp)

runStamps(appContainer, Hello, stamps)

Build a Stamp with Multiple Alternatives

A Component may vary its static DOM parts based upon static properties.

For a simple example:

import { jsx } from 'butterfloat'
import { type Observable, map } from 'rxjs'

export interface RollResultProps {
    faces: number
    roll: Observable<number>
}

function dieType(faces: number) {
    switch (faces) {
        case 6: return 'd6'
        case 20: return 'd20'
        default: return 'generic-roll'
    }
}

export function RollResult({ faces, roll }: RollResultProps) {
    const dtype = dieType(faces)
    const rollValue = roll.pipe(map((value: number) => value.toString()))
    return (
    <span class={`roll-result ${dtype}`} bind={{ innerText: rollValue }}></span>
    )
}

You can build a stamp for each of the variations:

import { buildStamp } from 'butterfloat'
import { JSDOM } from 'jsdom'
import { writeFile } from 'node:fs/promises'
import { NEVER } from 'rxjs'
import { RollResult } from './roll-result-component.ts'

const dom = new JSDOM(`
    <!doctype html>
    <html>
        <head>
            <title>Example Template</title>
        </head>
        <body id="app">
        </body>
    </html>
`)
const { window } = dom
const { document } = window
const appContainer = document.getElementById('app')!

const d6stamp = buildStamp(RollResult({ faces: 6, roll: NEVER }), document)
d6stamp.id = 'roll-result-d6'
appContainer.append(d6stamp)

const d20stamp = buildStamp(RollResult({ faces: 20, roll: NEVER }), document)
d20stamp.id = 'roll-result-d20'
appContainer.append(d20stamp)

const genericRollStamp = buildStamp(
  RollResult({ faces: 99, roll: NEVER }),
  document,
)
genericRollStamp.id = 'roll-result-generic'
appContainer.append(genericRollStamp)

await writeFile('index.html', dom.serialize())

To use these Stamps you register them with a Stamp collection and include a function to describe when the stamp applies based on the component's properties:

import { StampCollection, runStamps } from 'butterfloat'
import { Main } from './main.js'
import { RollResult } from './roll-result-component.js'

const appContainer = document.getElementById('app')!

const d6stamp =
  appContainer.querySelector<HTMLTemplateElement>('roll-result-d6')!
const d20stamp =
  appContainer.querySelector<HTMLTemplateElement>('roll-result-d20')!
const genericRollStamp = appContainer.querySelector<HTMLTemplateElement>(
  'roll-result-generic',
)!

const stamps = new StampCollection()
stamps
  .registerStampAlternative(RollResult, ({ faces }) => faces === 6, d6stamp)
  .registerStampAlternative(RollResult, ({ faces }) => faces === 20, d20stamp)
  .registerStampAlternative(
    RollResult,
    ({ faces }) => faces !== 6 && faces !== 20,
    genericRollStamp,
  )

runStamps(appContainer, Main, stamps)

Build a Stamp with a Test Context

To build a Stamp from a component that needs a Context, use the same makeTestContext and makeTestEvent you would for unit testing that same component.

import { type ComponentContext, type ObservableEvent, buildStamp, jsx, makeTestEvent, makeTestComponent } from 'butterfloat'
import { NEVER } from 'rxjs'

export interface WinButtonProps {}

export interface WinButtonEvents {
    click: ObservableEvent<MouseEvent>
}

export function WinButton(_props: WinButtonProps, { events }: ComponentContext<WinButtonEvents>) {
    return <button class='btn win-button' bindEvent={{ click: events.click }}>Click to Win!</button>
}

const { context: winContext } = makeTestComponentContext({
    click: makeTestEvent<MouseEvent>(NEVER)
})
export const winButtonStamp = buildStamp(WinButton({}, winContext))

Build a Prestamp

The general experience of Stamps is <template> tags that do not render anything until cloned while activating a Component. For "progressive enhancement" scenarios and other reasons you may want to stamp a template ahead of time to fill a rendered container. In StampCollection terms this is a "prestamp".

Example:

import { JSDOM } from 'jsdom'
import { writeFile } from 'node:fs/promises'
import { Main } from './main.js'

const dom = new JSDOM(`
    <!doctype html>
    <html>
        <head>
            <title>Example Template</title>
        </head>
        <body id="app">
        </body>
    </html>
`)
const { window } = dom
const { document } = window
const appContainer = document.getElementById('app')!

const mainStamp = buildStamp(Main(), document)
// Fill the container with the content of the template
appContainer.append(mainStamp.content)

await writeFile('index.html', dom.serialize())

You register a Prestamp in a similar way, including optional static property matcher, as any other Stamp:

import { StampCollection, runStamps } from 'butterfloat'
import { Main } from './main.js'

const appContainer = document.getElementById('app')!

const stamps = new StampCollection()
stamps.registerPrestamp(Main, appContainer)

runStamps(appContainer, Main, stamps)