npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@loom-js/core

v0.9.1

Published

A reactive, component-driven, JavaScript framework.

Readme

A reactive components-first JavaScript framework.

Feature Highlights

  • Micro-updates on rerenders - updates are made at the attribute & node-levels.
  • Self-cleanup leveraging native JS garbage collection & WeakMap to release dead nodes from memory.
  • Reactivity to rerender any number of components used within a component template.
  • Tagged Templates for performant processing of component templates.
  • Custom elements (defineElement) - consume loom components from any page as <some-element>.
  • Client-side Routing for dynamic rendering of components based on Location data.
  • Lazy-loading of routes & content (lazyImport), tracked by the settlement signal.
  • Server rendering (@loom-js/core/server) - render to an HTML string for SSR & SSG through the same code path the browser runs.
  • Client hydration (hydrate) - invisible takeover of pre-rendered pages: one atomic swap once the app has settled, no content flashes.
  • Dehydrated state (resourcedehydrateprimeResources) - hand the server's fetched data to the client, so a primed hydration never refetches.
  • 0 Dependencies (you're welcome)
  • Typescript Types included.

Install

npm i @loom-js/core
yarn add @loom-js/core
pnpm add @loom-js/core

Inclusion

import * as Loom from '@loom-js/core';

Concepts

Bootstrapping your application

Your app is your component ecosystem — one or more components that drive the application. Bootstrapping creates and configures it.

API init(options)

Inclusion import { init } from '@loom-js/core';

Arguments

  • interface AppInitProps = { app: ContextFunction; globalConfig?: AppGlobalConfig; onAppMounted?: (mountedApp: Element) => void; placement?: Placement; root?: Element | null; }

    • app - A ContextFunction which returns a single node (the app node) that will contain all other nodes from your app's component ecosystem, and it will eventually be appended to the app's root node once the initial render is complete.

    • placement - [Default: 'replace'] Where the app node lands relative to the root's existing children — type Placement = 'replace' | 'append' | 'prepend': 'replace' replaces them, 'append' inserts after them, 'prepend' inserts before them.

    • globalConfig - [Default: {}] Boot-time framework configuration — see Framework configuration.

    • onAppMounted - A callback function which gets called once the app node is appended to the desired DOM root node.

    • root - [Default: document.body] A DOM node which the app node is appended to once the initial render is complete. The document <head> & <body> can't serve as the root directly — when the root is omitted, null, or one of those, a fresh <div id="loom-app"> is prepended to the body & used instead.

Quick Example

import { init } from '@loom-js/core';

import { App } from './app';

init({
    app: App(),
    onAppMounted: (app) => {
        console.log(document.contains(app)); // => true
    },
    root: document.body
});

Framework configuration

Boot-time configuration rides in on init's (& hydrate's) globalConfig:

  • interface AppGlobalConfig = { debug?: boolean; debugScope?: ConfigDebugAllowable; events?: string[]; token?: string; }

    • debug & debugScope - Opt-in debug narration switches — see Diagnostics.

    • events - Extra event names appended to the defaults the template renderer recognizes for $event bindings (equivalent to calling appendEvents, below).

    • token - [Default: '⚡'] The placeholder token the template renderer uses during dynamic value resolution. Change it only if the default could collide with your content.

appendEvents(eventsToAppend) - The template renderer recognizes $event bindings for the standard GlobalEventHandlers set (click, input, change, …). If an event you bind isn't in that list — a custom event, or a newer DOM event — append it before the binding template renders:

import { appendEvents } from '@loom-js/core';

appendEvents(['my-custom-event']);

Inclusion import { appendEvents } from '@loom-js/core';

Components

A component uses a "tagged template" (w/ template literal syntax) - the template render function - to define its template.

Use component to register a template render function. It takes a render function as its argument, passing Loom's template renderer to the render function along with some props, and a getter for the component's rendered node. A template context is bound to the renderer to achieve optimal rerenders.

When using component, the tagged template's template string typically contains a single top-level element (one opening & closing tag pair wrapping the whole template). Fragment-rooted templates — starting with <>, or whose top level is only component elements — are the exception (see Composing components). An interpolated value at the top level doesn't qualify — give it the <> prefix.

API component<Props>(templateFunction)

Inclusion import { component } from '@loom-js/core';

Arguments

  • interface TemplateFunction = (html, props) => <the rendered template>

    • html (can be named anything) - the template render function ("tagged template") with the bound context.

      • Initializes a component template.
      • Once initialized, it efficiently handles updates to the same component using the bound context.

      Arguments

      • type TemplateLiteral = `my template literal`
        • Note - The template literal typically contains a single top-level Element (see above for the fragment-rooted exception).

      Returns The rendered template — return it straight from the template function.

    • props (can be named anything or destructured) - an object literal containing dynamic property values for enriching your component, along with a getter, node(), which returns the component's rendered node, and the five life-cycle hooks below - each hook takes a handler callback, and that handler receives the component's rendered node as an argument (see the section "Life Cycles" under "Examples" > "Components".)

Life-cycle hooks

| Hook | Fires | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------- | | onCreated | Once — on the first render, as soon as the component's root node exists (before it's in the document). | | onBeforeRender | On every render — after onCreated on the first render, before the template's dynamic values are applied. | | onRendered | On every render — after the template's dynamic values are applied. | | onMounted | When the component's node is attached to the live document (at boot's mount sweep, hydrate's swap, or a later DOM insertion). | | onUnmounted | When the component's node is removed from the live document. |

onMounted & onUnmounted describe a live, observed browser document — they never fire on the server (see Server rendering).

Returns Component The callable component function.

Quick Example

import { component } from '@loom-js/core';

interface ButtonProps {
    label: string;
    type: string;
}

export const Button = component<ButtonProps>(
    (html, props) => html`
        <button type="${props.type}">${props.label}</button>
    `
);

Attribute & text values. An interpolated attribute value on a plain element is applied when truthy & removed when falsy — that one rule gives you boolean attributes (disabled=${isDisabled}) and conditional attributes (aria-label=${labelOrUndefined}) for free. The number 0 is the deliberate exception: it is a real value, so tabindex=${0}, min=${0}, and a $attrs entry of 0 render as "0" (and value=${0} sets the element's value property). Text slots follow the same shape — ${0} renders 0, while undefined/null/false render as empty text.

import { component } from '@loom-js/core';

export const SaveButton = component<{ busy?: boolean; label?: string }>(
    (html, { busy, label }) => html`
        <button aria-label=${label} disabled=${busy} tabindex=${0}>
            Saved ${0} times
        </button>
    `
);

// busy: false, label: undefined
//   => <button tabindex="0">Saved 0 times</button>
// busy: true, label: 'Save'
//   => <button aria-label="Save" disabled tabindex="0">Saved 0 times</button>

Simple components (pass-through)

simple is the pass-through counterpart to component(): it wraps a render function that composes other components — no template, no component context of its own. Reach for it when a component's output is entirely another component's output: choosers, prop-mapping wrappers, convenience façades (core's own Picture is one — a <picture> component when sources is present, a bare <img> component when not).

API simple<Props>(render)

Inclusion import { simple } from '@loom-js/core';

Arguments

  • render - Receives the caller's props (always an object, even on propless calls — destructure freely) & returns one or more ContextFunctions from other components.

Returns SimpleComponent — callable exactly like a Component, including as a component element (<${Button} … />); props stay optional only while Props has no required members. Because a simple component renders no template of its own, it receives no life-cycle hooks & no node() getter — those belong to the component()s it composes.

import { simple } from '@loom-js/core';

import { IconButton, TextButton } from './buttons';
import type { ButtonProps } from './buttons';

export const Button = simple<ButtonProps>(({ icon, ...props }) =>
    icon ? IconButton({ icon, ...props }) : TextButton(props)
);

The result is callable exactly like any component — element syntax included:

import { component } from '@loom-js/core';

import { Button } from './button';

export const Toolbar = component(
    (html) => html`
        <div role="toolbar">
            <${Button} icon="icon-save" label="Save" />
            <${Button} label="Cancel" />
        </div>
    `
);

Using components

A defined component is just a function: calling it with props returns a ContextFunction, and a ContextFunction renders wherever a template accepts a value. The same component composes in two interchangeable forms — as a call, or as markup.

The call form, in a text slot:

import { component } from '@loom-js/core';

import { Button } from './button';

export const Actions = component(
    (html) => html`
        <div class="actions">
            ${Button({ label: 'Save', type: 'submit' })}
            ${Button({ label: 'Cancel' })}
        </div>
    `
);

The markup form — the same Actions, authored as elements:

export const Actions = component(
    (html) => html`
        <div class="actions">
            <${Button} label="Save" type="submit" />
            <${Button} label="Cancel" />
        </div>
    `
);

Calls also travel as plain values — into an array, a variable, another component's props:

const cancel = Button({ label: 'Cancel' });

export const Dialog = component(
    (html, { children }) => html`
        <dialog open>
            ${children}
            <div class="actions">${[Button({ label: 'OK' }), cancel]}</div>
        </dialog>
    `
);

Markup is the primary authoring surface, and the call form is its compiled equivalent — Composing components (element syntax), next, covers the syntax, its rules, and when each form fits.

Composing components (element syntax)

Components compose inside templates as elements, with props written as attributes:

// Any loom component works here — your own, or a library's.
const MenuButton = component(
    (html) => html`
        <${IconButton}
            isOnlyIcon
            icon="icon-menu"
            onClick=${() => toggleSideNav(null)}
        />
    `
);

This is sugar over the functional form — before the native parser runs, the template above compiles to the equivalent call, with no new runtime semantics.

A template whose top level is only component elements (and whitespace) renders as a rootless fragment, the same as templates that start with <> — so here, with no element left to root the template, the compiler prepends the fragment prefix:

// Thus, the previous example compiles to exactly this:
const MenuButton = component(
    (html) => html`
        <>
        ${IconButton({
            isOnlyIcon: true,
            icon: 'icon-menu',
            onClick: () => toggleSideNav(null)
        })}
    `
);

Only component tags earn that inference — a lone interpolated value at the top level still needs the <> prefix.

When the tag sits inside a real element, no inference is needed — the call simply takes its place and the root is untouched:

const Menu = component(
    (html) => html`
        <nav>
            <${IconButton} icon="icon-menu" onClick=${toggleMenu} />
        </nav>
    `
);

// compiles to:
const Menu = component(
    (html) => html`
        <nav>${IconButton({ icon: 'icon-menu', onClick: toggleMenu })}</nav>
    `
);

The two forms mix freely in one template and render identically. The compile step is cheap where it matters: templates with no component tags pass through byte-identical, and the transform runs once per template call site and is cached.

Markup vs. the functional form

So which form goes where? Element syntax is the primary authoring surface — compose in markup: elements, attributes, children, named slots. Reserve the functional call for genuine value positions, where a component must travel as a JS value. A .map item:

const TodoList = component(
    (html, { items }) => html`
        <ul>
            ${items.map((item) =>
                ListItem({ key: item.id, label: item.label })
            )}
        </ul>
    `
);

An effect callback's return:

const Main = component(
    (html) => html`
        <main>
            ${page.effect(({ value: topic }) =>
                topic ? TopicView({ topic }) : Loading()
            )}
        </main>
    `
);

An is= prop — a polymorphic root takes its component as a value, with el(tagName) covering plain tags:

const DocsCard = component(
    (html) => html`
        <${Card} is=${el('article')} heading="Docs">
            <p>The card renders an article root.</p>
        </>
    `
);

The functional form is not a legacy mode — it is exactly the compiled call shown above. Fragment-rooted components travel the same way anywhere a value goes: interpolated or in a children array, the reconciler moves their nodes as one group.

Props come in four forms, and the prop name is always taken verbatimonClick stays onClick, with no lowercasing (component tags never reach the native HTML parser):

| Form | Compiles to | Notes | | --------------- | ------------------ | --------------------------------------------------------------- | | name | { name: true } | boolean shorthand | | name="text" | { name: 'text' } | static string; single or double quotes; no HTML entity decoding | | name=${value} | { name: value } | any JS value, passed by reference — objects, arrays, functions | | ...${object} | { ...object } | spread; JS object-spread semantics, no whitespace after ... |

Spread props apply with object-literal semantics: spreads and named props land in authored order with last-wins duplicates —

const headerProps = { className: 'header', id: 'top' };

// `className` lands after the spread, so it wins — exactly like
// `Header({ ...headerProps, className: 'hero' })`.
const PageHeader = component(
    (html) => html`
        <${Header} ...${headerProps} className="hero" />
    `
);
// => Header receives { className: 'hero', id: 'top' }

Nullish and primitive spread values are a render-time no-op, matching { ...null } in JS:

// Renders as if the spread weren't there.
const PageHeader = component(
    (html, { maybeProps }) => html`
        <${Header} ...${maybeProps ?? null} id="top" />
    `
);

A slot key inside a spread object arrives as an ordinary prop, never as a slot label (labels are resolved at transform time), and markup-derived children/slots still win over spread-supplied ones:

// `slot` stays a plain prop, and the markup children win over
// `spread.children`.
const spread = { children: 'ignored', slot: 'header' };

const SpreadCard = component(
    (html) => html`
        <${Card} ...${spread}>
            <p>These children win.</p>
        </>
    `
);

No $ sigil on component tags. On a component tag, write onClick=${fn} — never $onClick=${fn}. The $ sigil belongs to real elements, where it marks the renderer's own bindings — $click, $attrs, $on, $props. A component element needs no marker: every attribute is already a prop, so a $-prefixed prop there carries no meaning, and throws.

// Component tag: every attribute is a prop — no sigil.
const MenuTag = component(
    (html) => html`
        <${IconButton} onClick=${toggleMenu} label="Menu" />
    `
);

// Real element: `$` marks loom's element bindings.
const MenuElement = component(
    (html) => html`
        <button $click=${toggleMenu} class="button">Menu</button>
    `
);

// Throws on first render — `$` carries no meaning on a component tag.
component(
    (html) => html`
        <${IconButton} $onClick=${toggleMenu} label="Menu" />
    `
);

The split carries real information. $click on a real element tells the renderer to attach a listener to that node; onClick on a component is a plain value whose meaning the component owns — it may bind it to an inner element, wrap it, or never touch the DOM:

// The same prop, two owners' meanings: bound to an inner element…
const Chip = component(
    (html, { label, onClick }) => html`
        <button $click=${onClick} type="button">${label}</button>
    `
);

// …or wrapped, so the component decides when — or whether — it fires.
const ConfirmChip = simple(({ label, onClick }) =>
    Chip({
        label,
        onClick: (event) => confirm('Are you sure?') && onClick?.(event)
    })
);

There is no general answer to which node a component-level $click would target — a fragment root has none; a card's button may sit levels deep — so the sigil stays where the target is certain.

In practice the hookup is still nearly free: components built over el() — core's element components included — forward onClick to their element automatically:

// `el()` maps `onClick` to `$click` on the element it renders — no wiring.
const SaveButton = simple(({ onClick }) =>
    el('button')({ children: 'Save', onClick })
);

Children go between the opening tag and the single closing form, </>:

const DocsPanel = component(
    (html, { interpolations, label }) => html`
        <${Panel} heading="Docs">
            <p>Any markup, ${interpolations}, and nested components:</p>
            <${Chip} label=${label} />
        </>
    `
);

The wrapped markup reaches the component as its children prop, rendering in its own component context. </> always closes the innermost open component tag — </${Panel}> (the JSX-style guess) and <//> (htm's closing form) are not accepted and throw, naming the fix.

Named slots give a component more than one labelled content region — children, plus any number of named regions the component places wherever it likes. The contract has two sides.

The component's side: alongside children, its props carry a slots object with one key per region name. Interpolating slots.name decides where that region renders:

export const Card = component(
    (html, { children, slots }) => html`
        <article>
            <header>${slots?.header}</header>
            <div>${children}</div>
            <footer>${slots?.footer}</footer>
        </article>
    `
);

The caller's side: a top-level child carrying a slot="name" label — a plain element or a component element — is grouped into slots.name instead of children:

const TitledCard = component(
    (html, { label }) => html`
        <${Card}>
            <h2 slot="header">Title</h2>
            <p>Everything unlabelled stays ordinary children.</p>
            <${Chip} slot="footer" label=${label} />
        </>
    `
);

The functional form is the same call the element syntax compiles to:

Card({
    slots: {
        footer: Chip({ label }),
        header: el('h2')({ children: 'Title' })
    },
    children: el('p')({
        children: 'Everything unlabelled stays ordinary children.'
    })
});

A named region arrives with no wrapper element: interpolating it drops the labeled nodes in place as bare siblings, and the region renders as its own unit — its nodes reconcile and move together, like any fragment. An absent region simply renders nothing. Multiple same-label siblings concatenate in source order.

The label itself leaves different traces. A plain element keeps its slot attribute in the rendered DOM — inert, exactly as the platform leaves it on natively assigned nodes. On a component element, slot exists only to name the region its content belongs to — consumed at transform time, never reaching the component as a prop:

const FooterCard = component(
    (html, { label }) => html`
        <${Card}>
            <!-- Same label: both land in slots.footer, in source order. -->
            <a slot="footer" href="/docs">Docs</a>
            <a slot="footer" href="/about">About</a>
            <!-- Plain element: the rendered <a> keeps slot="footer". -->
            <!-- Component element: \`slot\` is consumed at transform time — Chip never sees it. -->
            <${Chip} slot="footer" label=${label} />
        </>
    `
);

Slot labels are recognized only on top-level children and must be static, non-empty, quoted strings:

// Each throws at transform time — labels resolve before render:
component((html) => html`<${Card}><p slot=${name}>…</p></>`); // interpolated
component((html) => html`<${Card}><p slot>…</p></>`); // bare
component((html) => html`<${Card}><p slot=name>…</p></>`); // unquoted
component((html) => html`<${Card}><p slot="">…</p></>`); // empty

Deeper slot attributes keep their native meaning and pass through untouched; loom's distribution applies to the light DOM only, and never competes with native <slot> distribution — a shadow-rooted custom element written as markup is left entirely to the platform:

const MixedCard = component(
    (html) => html`
        <${Card}>
            <!-- Top level: loom's label — lands in slots.header. -->
            <h2 slot="header">Title</h2>
            <!-- Nested inside a custom element: the platform's slot,
                distributing into my-el's shadow DOM. Loom passes it
                through. -->
            <my-el>
                <span slot="x">Shadow-distributed by the browser.</span>
            </my-el>
        </>
    `
);

The key prop. key is an ordinary prop (key=${item.id}) and participates in keyed reconciliation exactly as Component({ key }) does:

const todos = activity<Todo[]>([]);

const TodoList = component(
    (html) => html`
        <ul>
            ${todos.effect(({ value: items }) =>
                items.map((item) =>
                    TodoItem({ key: item.id, label: item.label })
                )
            )}
        </ul>
    `
);

Keys are what make reordering cheap and safe: on an update, a keyed item's rendered nodes are moved, not rebuilt — node identity survives, so an item's input value, focus, or scroll position rides along with it. Without keys, a reorder re-renders items in place instead.

Children of keyed items move with their parents automatically; no key is needed on inner component elements — and a keyed fragment-rooted item moves as one group, every top-level node relocating together.

Errors. Malformed component syntax throws on the template's first render — naming the offending construct and quoting the surrounding template text — rather than falling through to the native parser and silently mis-rendering. This covers unclosed tags, unmatched </>, $-prefixed props, unquoted attribute values (a=b), interpolations inside quoted values (a="x ${y}" — use a=${`x ${y}`} instead), and ... not immediately before an interpolation.

Template comments

HTML comments (<!-- … -->) are the way to annotate a template — they work anywhere, component tags' children regions included, and become real Comment nodes, present in the rendered DOM and in server output. One character needs care: a backtick inside a template — comments included — belongs to the template literal, so escape it (\``) and it arrives as a literal backtick. For the rare annotation that should leave no trace in the DOM at all, interpolate an empty string with a JS comment beside it — ${'' /* … */}` renders nothing:

const Annotated = component(
    (html, { content }) => html`
        <div>
            <!-- A real comment node — visible in the DOM & server markup. -->
            ${'' /* Renders nothing: the value is '', the JS comment is free. */}
            ${content}
        </div>
    `
);

Element components

Core ships a small set of kit-agnostic, tree-shakeable element components — element-level building blocks that carry real behavior (plain structure is better authored as markup):

RouteLink — an anchor wired to the SPA router. Same-origin activations route via route() with no caller-supplied handler; target="_blank" and cross-origin hrefs fall through to the browser default (ctrl/cmd-click keeps its native new-tab behavior via the router itself).

const DocsLink = component(
    (html) => html`
        <${RouteLink} href="/docs">Docs</>
    `
);

Svg — sprite composition: renders an <svg fill="currentColor"> whose <use> references path#svgId. size sets both dimensions; height/width set them individually, defaulting to 1em.

const HomeButton = component(
    (html) => html`
        <button $click=${route} type="button">
            <${Svg} path="/static/svg/sprite.svg" svgId="logo" size="20" />
            Home
        </button>
    `
);

Picture — responsive image. With a sources array it renders a <picture> containing one <source> per entry plus the <img>; without one it renders the <img> alone. SourceProps is exported for typing the entries.

import { component, Picture, type SourceProps } from '@loom-js/core';

const sources: SourceProps[] = [
    { media: '(width >= 768px)', srcset: '/img/hero-wide.avif' },
    { srcset: '/img/hero.avif' }
];

// With sources: <picture> wrapping one <source> per entry + the <img>.
const Hero = component(
    (html) => html`
        <${Picture}
            alt="Sunrise over the loom"
            loading="lazy"
            sources=${sources}
            src="/img/hero.jpg"
        />
    `
);

// Without: just the <img>.
const HeroPlain = component(
    (html) => html`
        <${Picture} alt="Sunrise over the loom" src="/img/hero.jpg" />
    `
);

el(tagName) — a plain HTML tag as a component value, for the places element syntax needs an element as a value — polymorphic is= props, third-party render callbacks, props transformers.

// A polymorphic root: the card renders a <section> this time.
const DocsSection = component(
    (html) => html`
        <${Card} is=${el('section')} heading="Docs">
            <p>Card content.</p>
        </>
    `
);

// An API that asks *you* for a component value — here a `heading` prop —
// gets a plain tag the same way it would get any component:
const Section = component<{ heading: ContextFunction }>(
    (html, { children, heading }) => html`
        <section>
            <header>${heading}</header>
            ${children}
        </section>
    `
);

const ReleaseNotes = component(
    (html) => html`
        <${Section}
            heading=${el('h2')({
                children: 'Releases',
                className: 'heading-level-4'
            })}
        >
            <p>Everything in the latest release.</p>
        </>
    `
);

Memoized per tag, so re-renders reuse DOM nodes:

el('footer') === el('footer'); // => true — one component per tag name

Void tags — self-closing elements that can't take children — render childless:

const Divider = component(
    (html) => html`
        <div>
            ${el('hr')({ className: 'divider' })}
            ${el('img')({ attrs: { alt: 'Logo', src: '/img/logo.png' } })}
        </div>
    `
);

Prefer writing markup when you can; reach for el() only where a component reference must travel as a JS value — the same test that picks the functional form:

// The tag travels as data: which element renders is decided by a value.
const statusTag = { error: el('strong'), info: el('span') };

const StatusLine = component(
    (html, { level, message }) => html`
        <p>${statusTag[level]({ children: message })}</p>
    `
);

Custom elements

component() defines a component for use inside loom templates. It does not define a custom element. When you want a component to be consumable from a non-loom page as <some-element>, define it with defineElement() instead — it is component() plus registration, and returns the same callable Component.

API defineElement<Props>(name, templateFunction, options?)

| Argument | Type | Description | | ------------------ | ------------------------- | --------------------------------------------------------------------------------------------------------------- | | name | string | The custom element name. Must start with a lowercase letter, contain a hyphen, and use no uppercase characters. | | templateFunction | TemplateFunction<Props> | The render function, exactly as you would pass to component(). | | options.shadow | ShadowRootInit \| false | Render into a shadow root. Defaults to false (light DOM). | | options.styles | CSSStyleSheet[] | Stylesheets adopted by the shadow root. Ignored without shadow. |

Returns Component — the same callable component function, so it still composes normally inside other loom templates.

import { component, defineElement } from '@loom-js/core';

// Composable in loom templates. No custom element is defined.
export const Card = component(
    (html, props) => html`
        <div>${props.children}</div>
    `
);

// Also available to any page as <fancy-button>.
export const FancyButton = defineElement<ButtonProps>(
    'fancy-button',
    (html, props) => html`
        <button type="${props.type}">${props.label}</button>
    `
);

Use one or the other for a given component, never both. An invalid or already-taken element name throws immediately, naming the element.

Passing props from a consuming page

Consumers pass props as $-prefixed attributes, which map to camelCase props:

<fancy-button $label="Save" $type="submit"></fancy-button>

From inside a loom template, a $-prefixed interpolated value is set as a real JS property rather than an attribute, so objects, arrays, and functions arrive uncoerced:

const SaveControls = component(
    (html, { handleClick, label }) => html`
        <fancy-button $label=${label} $onClick=${handleClick}></fancy-button>
    `
);

Child nodes of the host element arrive as the children prop.

Light DOM vs. shadow DOM

By default a registered element renders into the light DOM — its content is an ordinary part of the document tree, and your application and design-system CSS applies to it with no extra work.

Pass shadow to encapsulate instead. Be aware that a shadow root blocks document stylesheets entirely, so a shadow-rooted component is unstyled until you supply its styles. Two mechanisms carry styles across the boundary:

  1. CSS custom properties, which pierce the shadow boundary by inheritance. This is what makes theming work with no additional wiring.
  2. options.styles, adopted onto the shadow root for the component's own CSS:
const buttonStyles = new CSSStyleSheet();

buttonStyles.replaceSync(`
    button { padding: var(--p-space-4); color: var(--p-color-text); }
`);

export const FancyButton = defineElement<ButtonProps>(
    'fancy-button',
    (html, props) => html`
        <button>${props.label}</button>
    `,
    { shadow: { mode: 'open' }, styles: [buttonStyles] }
);

mode should stay 'open'. 'closed' provides no additional style encapsulation — it only makes element.shadowRoot unreachable, which breaks tests, devtools, and external queries.

Why light DOM is the default. This is a deliberate call, not a rejection of encapsulation. Loom ships no styling machinery for shadow content beyond options.styles, so a shadow-by-default element would render completely unstyled the first time anyone reached for defineElement. The orthodox web-components position is the opposite — with shadow, a consuming page's CSS cannot break your component and your CSS cannot leak into their page — and when that trade fits, encapsulation is one option away: pass shadow. Light DOM stays the default.

Known limitations

  • Registration must happen before a consuming template is parsed. For $prop=${value} to reach a JS property, the element must already be upgraded when the template renders. In a bundled app this is automatic. Under lazy loading or an unbundled module graph, import the defining module before rendering a template that uses its element — otherwise the value is silently set as a string attribute. Loom warns when it detects this.
  • Attributes are read once, at connectedCallback. There is no observedAttributes support yet, so changing a $-attribute on an already-connected element does not re-render it.

Activities (reactivity)

At its core, an activity is a pub/sub around a single value.

When creating a new activity, you provide an initial value — & optionally a transform and/or options. One or more effects may be queued within your component ecosystem for any given activity. Then, by hooking an activity update to some event, all subscribed effects will be called in order of "first-in, first-out".

API activity<V, I = V>(initialValue, transformOrOptions?, options?)

V is the stored value type; I is the update() input type, which only differs from V when a transform maps one to the other.

Inclusion import { activity } from '@loom-js/core';

Arguments

  • initialValue: V - The starting value. reset() returns to it, & it stays available as the frozen initialValue property, so the baseline can't drift.
  • transformOrOptions?: ActivityTransform<V, I> | ActivityOptions<V, I> - Either the transform function, or the options object when no transform is needed.
  • options?: ActivityOptions<V, I> - The options object, when the second argument is a transform.

Transforms (the async-data path)

A transform sits between update() & the stored value: every update(input) call routes through it, & only the transform's own update calls commit values. The initial value doesn't take this path — it's stored as-is at creation, untransformed; the transform first runs on the first dispatch. It receives one context object:

  • input: I - Whatever the caller passed to update() — may be a different type than the stored V.
  • update(next: V) - Commits a value; call it as many times as needed. It deliberately shadows the activity's own update: inside a transform, updating is committing — dispatching the activity from within its own transform would loop infinitely.
  • value: V - The current value at the moment the update was dispatched — a shallow copy for plain objects & arrays (not frozen), so it is a safe base to build on. It is bound once per run: it does not move across awaits, and it does not reflect the run's own commits.

An async transform's returned promise is tracked by the settlement signalsettled(), the signal renderToString & hydrate gate on — which is what lets server renders & hydration swaps wait for activity data to land. This makes transforms the framework's idiomatic path for async data (see Server rendering, Client hydration & Dehydrated state):

import { activity } from '@loom-js/core';

const page = activity<PageData | undefined, string>(
    undefined,
    async ({ input: slug, update }) => {
        update(await fetchPage(slug));
    }
);

// Callers pass the transform's input type — here, the slug string.
page.update('docs/intro');

A transform may commit more than once per run — each commit notifies effects, so intermediate states paint:

import { activity } from '@loom-js/core';

type SearchState =
    { status: 'loading' } | { status: 'ready'; results: Result[] };

const search = activity<SearchState, string>(
    { status: 'loading' },
    async ({ input: query, update }) => {
        // First commit: effects render the loading state immediately.
        update({ status: 'loading' });
        // Second commit: effects re-render with the data.
        update({ status: 'ready', results: await searchDocs(query) });
    }
);

Accumulating within a run seeds from value once, then builds locally — value is a dispatch-time snapshot, so re-reading it per commit would drop the run's earlier commits:

const feed = activity<Result[], string>(
    [],
    async ({ input: query, update, value }) => {
        // Seed from the committed value at dispatch, accumulate locally.
        let results = [...value];

        for await (const batch of searchPages(query)) {
            results = [...results, ...batch];
            // Each commit paints everything gathered so far.
            update(results);
        }
    }
);

Options (ActivityOptions)

  • deep?: boolean - [Default: false] Compare plain objects property-by-property & arrays element-by-element (a shallow diff) instead of by reference, so a same-content update doesn't cascade to subscribed effects.
  • force?: boolean - [Default: false] Treat every update as a change, skipping comparison entirely.
  • transform?: ActivityTransform<V, I> - The transform, for when options ride in the second argument.

Returns

  • Interface

    Properties

    • initialValue
      • The value the activity started with. Frozen (Object.freeze) when it's an object, so the reset baseline can't be mutated.

    Methods

    • effect(({ value }) => TemplateTagValue)
      • An effect is called at least once per use, when it's first introduced during the component render process. Additionally, it's called once per activity update.
      • value - the initial activity value, or the new value on updates.
      • The action returns what renders in the effect's slot — idiomatically a called component (a ContextFunction), though any interpolatable TemplateTagValue is accepted.
    • bind(select?)
      • Creates a reactive attribute binding for template attr slots — the bound attribute applies select of the current value immediately and stays in sync with every update, without re-rendering the component. Cleanup is automatic: the binding is disposed when a re-render replaces the slot's value and on unmount.
      • select - projects the activity value to the attribute value; defaults to identity.
      • Prefer bind over an effect boundary when only an attribute depends on the activity; prefer effect when content or structure changes.
    • reset()
      • Shorthand for update(initialValue) — returns the activity to its starting value (subject to the same change comparison as any update).
    • update(input, forceUpdate?)
      • Calling this method will trigger all subscribed effects from the related activity, passing the new value to each effect. With a transform, input is handed to the transform (which commits through its own update); without one, input is stored directly.
      • forceUpdate - [Default: the force option] Treat this one update as a change regardless of comparison.
    • value()
      • A getter which returns the current value, initially initialValue. Plain objects & arrays come back as a shallow copy, so mutating the returned value can't defeat change detection — commit changes through update().
    • watch(action)
      • Subscribes a caller-managed handler: action runs immediately with the current value, then on every update.
      • Returns an Unsubscriber — cleanup is the caller's responsibility (e.g. pair it with onUnmounted), unlike effect (context-managed) and bind (template-managed).

Attribute binding example

import { activity, component } from '@loom-js/core';

const isOpen = activity(false);

const Panel = component(
    (html) => html`
        <section
            class=${isOpen.bind((open) => (open ? 'panel _open' : 'panel'))}
        >
            Content is untouched when the class updates.
        </section>
    `
);

Quick Example

import { activity } from '@loom-js/core';

const initialValue = 0;
export const buttonClickActivity = activity(initialValue);
console.log(buttonClickActivity.initialValue); /* => 0 */

console.log(buttonClickActivity.value()); /* => 0 */
buttonClickActivity.update(1);
console.log(buttonClickActivity.initialValue); /* => 0 */
console.log(buttonClickActivity.value()); /* => 1 */

See the Activity Example, below, for an effect() usage example.

Routing

Routing is used specifically for single-page-apps (SPA). You can still set up server-side routes as you would for a multi-page app, and then let the client-side routing take over to achieve a snappy single-page-app experience. This approach would also work well for a prerendered static site.

The routing system is one layered pipeline per DOM window, built on the activity system and the browser's native History API. Every navigation flows through a raw location layer (always fires, no configuration needed), whose match transform feeds the route layer (fires when a registered route matches):

  • Layer 1 — location: zero-config reactivity to the raw LocationlocationEffect, watchLocation.
  • Layer 2 — routes: route-table matching with lazy-loaded pages — createRoutes, routeEffect, watchRoute.

In the browser there is exactly one router for the lifetime of the page; on a server each injected window resolves its own isolated instance (see Server rendering).

API

  • createRoutes({ config, fallback, guard }) - Registers the app's route table & returns the routes component to compose into your layout tree. Each config entry maps a route path (dynamic segments via /:param) to an importer of the page component — () => import('@app/pages/about'), matched on the module's default export.
    • DOM-free at call time: calling it at module scope is safe in any runtime, including off-browser. History wiring defers to first use inside a DOM scope.
    • Calling it again replaces the route table — last call wins (a call without guard clears any registered one).
    • fallback?: () => Promise<ContextFunction | undefined> - Rendered while no page has loaded.
    • guard?: (routeValue: RouteValue) => boolean - A synchronous predicate run on every valid match with the candidate RouteValue (matchedRoute, params, pathname, raw), before the route emission. Returning false suppresses the emission — route effects & watchers don't fire & the page content stays put (the fallback on first load) — while the raw location layer (layer 1) still observes the navigation. See the guard semantics below.
  • route(event, options?) - The click-handler for SPA navigation; wraps history.pushState. Modified activations (ctrl/cmd/shift/alt-click) & events another handler already consumed fall through to the browser.
    • event - Pass the click event through (route directly as the handler, or (e) => route(e, options)); pass null when navigating programmatically via options.href.
    • options
      • href?: string - The target url - overrides the anchor's href attribute.
      • replace?: boolean - Uses replaceState so the address bar updates without adding a history entry.
      • scroll?: boolean - [Default: true] Set false to keep the viewport still: suppresses every scroll the navigation would perform — fragment scrolls & the fragmentless scroll-to-top alike — while everything else about the navigation is unchanged.
  • routeEffect(routeEffectCallback) - An effect over the matched route. The callback receives { value: RouteValue }matchedRoute, params, pathname & raw (the Location) — & returns what renders in the effect's slot: idiomatically a called component, though any TemplateTagValue is accepted (the same contract as activity.effect). Requires a registered route table.
  • watchRoute(handler) - The non-rendering watcher form of routeEffect; returns an unsubscriber.
  • locationEffect(locationEffectCallback) - An effect over the raw Location. Zero-config — no route table required — & re-runs on every navigation. The callback receives { value: Location } & returns what renders in the effect's slot — the same contract as activity.effect.
  • watchLocation(handler) - The non-rendering watcher form of locationEffect; returns an unsubscriber.
  • redirect(href) - Programmatic replace-state navigation.
  • RouteLink - A pre-wired SPA anchor — see Element Components.

Inclusion import { createRoutes, locationEffect, route } from '@loom-js/core';

Quick Example

import { RouteLink, component, createRoutes } from '@loom-js/core';

// Module scope is fine — registration is DOM-free.
const Routes = createRoutes({
    config: {
        '/': () => import('@app/pages/home'),
        '/docs/:slug': () => import('@app/pages/docs')
    }
});

export const App = component(
    (html, props) => html`
        <div>
            <nav>
                ${RouteLink({ children: 'Home', href: '/' })}
                ${RouteLink({ children: 'Docs', href: '/docs/intro' })}
            </nav>
            <main>${Routes(props)}</main>
        </div>
    `
);

Pages receive the matched route as routeProps (a RouteValue) — e.g. /docs/:slug exposes routeProps.params.slug.

Route guard. A guarded-out navigation still moves the URL: route() pushes history before the match transform runs, so the guard suppresses content, not the address bar. An auth-style flow handles that by redirecting inside the guard — redirect() replace-states over the suppressed entry:

import { createRoutes, redirect } from '@loom-js/core';

import { isAuthenticated } from '@app/auth';

const Routes = createRoutes({
    config: {
        '/': () => import('@app/pages/home'),
        '/login': () => import('@app/pages/login'),
        '/account': () => import('@app/pages/account')
    },
    guard: ({ pathname }) => {
        if (pathname === '/account' && !isAuthenticated()) {
            redirect('/login');
            return false;
        }

        return true;
    }
});

Loop avoidance is caller-owned: the guard must pass its own redirect target (here /login returns true), or every navigation suppresses & redirects forever.

Hash / anchor navigation. route() restores the native anchor jump its preventDefault suppresses, scrolling the element whose id matches the url's #fragment into view (a bare trailing # scrolls to the top):

  • Same-page (#fragment-only navigation): scrolls immediately. The activity pipeline stays quiet — no location or route emission, no page reload.
  • Cross-page (navigation with a fragment that changes the route): the fragment is held until the routed page's tracked async work settles, then its target scrolls into view.
  • Initial load (app boots on a url carrying a fragment): the browser's native scroll fired before lazily-imported content existed, so the router scrolls once settlement resolves.

The deferred scroll is a single attempt, fired once the settlement signal resolves (bounded, like hydrate's settle window) — so anchors produced by framework-tracked async work (lazy route chunks, content fetched through activity transforms) exist by scroll time. If the target id still doesn't exist — async work outside the tracking boundary — nothing scrolls and the app owns its own scroll from there. A subsequent navigation drops any unconsumed fragment. Scrolling uses scrollIntoView(), so the page's scroll-behavior CSS controls smoothness.

Fragmentless navigations land at the top. A route-changing route() with no fragment scrolls the window to the top as soon as the navigation commits — instantly, bypassing scroll-behavior CSS, the way a fresh document load lands. (Only fragment scrolls wait on settled content: their target has to render first; a top scroll has no target.) History traversal & reloads restore exactly: the router owns scroll restoration (history.scrollRestoration = 'manual'), captures the entry's offset on the way out, & replays it once the arrived content settles — so the saved position is computed against a fully-rendered page (the browser's own restoration clamps against the short, still-loading document). An entry with no saved offset stays at the top.

Pass { scroll: false } when the scroll itself is unwanted — the viewport stays put while the URL, history & pipeline behave exactly as above. The opt-out is for caller-owned cases where the user is already at the target, e.g. an in-page "copy link" anchor beside a heading; any scrolling from there is the caller's.

When you want to react to the url without a route table — a breadcrumb, an analytics hook, a tiny app that switches on pathname — use layer 1 directly:

import { component, locationEffect, route } from '@loom-js/core';

import { About, Home, NotFound } from '@app/component/pages';

export const App = component(
    (html) => html`
        <div>
            <nav>
                <a $click="${route}" href="/">Home</a>
                |
                <a $click="${route}" href="/about">About</a>
            </nav>
            <main>
                ${locationEffect(({ value: { pathname } }) => {
                    switch (pathname) {
                        case '/':
                            return Home();
                        case '/about':
                            return About();
                        default:
                            return NotFound();
                    }
                })}
            </main>
        </div>
    `
);

Lazy imports

lazyImport wraps a dynamic import() in an activity, so lazily-loaded content composes like any other async data: subscribe with effect, & the import's promise is tracked by the settlement signalrenderToString & hydrate wait for it. The result is cached per key for the life of the page: repeat calls with the same key return the same activity without re-importing (createRoutes loads its route pages through this same machinery).

API lazyImport<ImportType>(key, importer)

Inclusion import { lazyImport } from '@loom-js/core';

Arguments

  • key: string | Symbol - The cache key.
  • importer: () => Promise<ImportType> - The import function — e.g. async () => (await import('./chart')).Chart.

Returns The import's activity — a LazyImportActivity<ImportType>, so effect, watch & value() carry ImportType | undefined: undefined until the import resolves, then whatever the importer's promise resolved to.

import { component, lazyImport } from '@loom-js/core';

import { Loading } from './loading';

const chart = lazyImport('chart', async () => (await import('./chart')).Chart);

export const Dashboard = component(
    (html) => html`
        <section>
            ${chart.effect(({ value: Chart }) => (Chart ? Chart() : Loading()))}
        </section>
    `
);

importLazy(path, importer?) - A convenience over lazyImport typed for renderable content: the importer resolves a ContextFunction | undefined (defaulting to undefined), & the path doubles as the cache key.

import { component, importLazy } from '@loom-js/core';

import { Loading } from './loading';

// No invented key — the path doubles as it. No undefined-dance in the
// effect — the importer resolves content already ready to render.
const chartPanel = importLazy('./chart-panel', async () =>
    (await import('./chart-panel')).ChartPanel()
);

export const Dashboard = component(
    (html) => html`
        <section>
            ${chartPanel.effect(({ value }) => value ?? Loading())}
        </section>
    `
);

Server rendering (SSR & SSG)

renderToString renders an app to an HTML string outside the browser — at request time (SSR) or build time (SSG/prerender). It runs the exact same render path the client does, against an injected DOM implementation, so server and client markup cannot drift. loom never imports the DOM implementation itself; you supply a DOM window (we recommend linkedom — small, fast, purpose-built for this).

API

  • renderToString(app: ContextFunction, options) - The go-to render (async). Renders app against the injected DOM, waits on the settlement signal (settled()) — so createRoutes route pages, lazyImport content & async activity data serialize in place, however long they take — & resolves the document body's innerHTML. Concurrent calls are safely serialized internally.
    • options
      • window - The DOM to render against, e.g. parseHTML(...).window from linkedom. Use a fresh window per render — never share one across concurrent renders.
      • url?: string - The request URL. Installed as the window's location, so locationEffect & createRoutes match the requested path.
      • maxWait?: number - Upper bound in ms (default 4000) on the settlement wait — symmetric with hydrate's maxWait. On expiry the render serializes whatever has landed & a framework console warning names the still-pending count. Infinity disables the bound. Ignored by renderToStringSync.
  • renderToStringSync(app: ContextFunction, options) - The synchronous primitive: whatever has rendered when the app's synchronous work completes is what serializes (the naming follows Node's readFile/readFileSync pairing). Right for route-less renders — fragments, email/OG markup, component snapshot tests. A route-table app serializes only its shell/fallback here, since page importers cannot settle inside a synchronous pass. Same options.

Inclusion import { renderToString } from '@loom-js/core/server';

Quick Example

import { renderToString } from '@loom-js/core/server';
import { parseHTML } from 'linkedom';

import { App } from '@app/app';
import { readFile } from 'node:fs/promises';

export const handleRequest = async (request: Request) => {
    // One window per render (per request, or per page when prerendering).
    const { window } = parseHTML('<html><body></body></html>');
    const markup = await renderToString(App(), {
        url: request.url,
        window
    });

    // Inject the markup into your HTML shell however you like — here, a
    // served shell file carrying an <!--app--> placeholder.
    const shellTemplate = await readFile('./shell.html', 'utf8');

    return shellTemplate.replace('<!--app-->', markup);
};

The shell is an ordinary HTML file you own — the placeholder marks where the app lands, and the script tag loads the same client bundle that will take the page over:

<!doctype html>
<html>
    <head>
        <title>My app</title>
        <script defer src="/client.js" type="module"></script>
    </head>
    <body>
        <div id="page-content"><!--app--></div>
    </body>
</html>

handleRequest is fetch-shaped, so it wires into any modern server runtime — a serverless function, a framework that speaks Request/Response (Hono, SvelteKit-style adapters), or Node's http via a small adapter. And the client entry — a separate, browser-only module — boots on top of the served markup:

// client.ts — the browser entry `/client.js` is bundled from. Note the
// import arrows: this module and the server handler both import the shared
// `@app/app` component module (import-safe off-browser, no DOM at module
// scope); neither imports the other. Only the shell's script tag loads this
// file, in the browser — where `document` is the native global, the real
// page parsed from the HTML the server sent.
import { hydrate } from '@loom-js/core';

import { App } from '@app/app';

hydrate({ app: App(), root: document.querySelector('#page-content') });

The full loop, then: serve handleRequest's HTML, and /client.js takes it over — keeping top-level document access in the browser-only entry, never in modules the server imports — hydrate's own semantics (the settle-and-swap, its gates) are Client hydration's subject, below.

Choosing a DOM implementation

Any window-shaped DOM implementation can back a server render — loom touches only standard surface (document parsing, importNode, tree walking, per-window customElements). linkedom stays the recommendation: small, fast, and purpose-built for exactly this. jsdom is also verified — heavier, but the most spec-complete, and a natural fit when your server tests already run on it:

import { JSDOM } from 'jsdom';

export const handleRequest = async (request: Request) => {
    // Only the window creation changes — one fresh window per render,
    // same contract as linkedom.
    const { window } = new JSDOM('<html><body></body></html>');

    // …the rest is identical to the linkedom handler above.
};

Two honest boundaries:

  • Happy DOM does not work today — its strict attribute validation trips a known loom bug (tracked; support is planned).
  • Pick one implementation per process: parsed templates cache against the first render's document, so mixing implementations in a single process hands one library's nodes to another's APIs.

Prerendering (SSG)

Prerendering an app at build time runs through renderToString — one render per page, against a fresh injected window:

import { renderToString } from '@loom-js/core/server';
import { parseHTML } from 'linkedom';

import { App } from '@app/app';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';

// Enumeration is yours: loom renders URLs, it doesn't discover them. List
// every page — from your route table, a CMS listing, or the filesystem.
const routes = ['/', '/docs/intro', '/docs/activities'];

// The bundler's emitted shell already links the hashed client bundle, so
// reusing it keeps scripts & styles wired without hand-maintenance.
const shell = await readFile('./dist/index.html', 'utf8');

for (const route of routes) {
    // A fresh window per page — renders must not share state.
    const { window } = parseHTML('<html><body></body></html>');
    // Render the same App the browser boots: the router matches `url`,
    // imports that route's page, & settlement holds the render until the
    // page's tracked content lands.
    const markup = await renderToString(App(), {
        url: `https://example.com${route}`,
        window
    });
    const outDir = path.join('./dist', route);

    await mkdir(outDir, { recursive: true });
    await writeFile(
        path.join(outDir, 'index.html'),
        shell.replace('<!--app-->', markup)
    );
}

Serve ./dist statically and every listed route is a real page. Two notes close the loop: an app without a route table prerenders the same way by rendering the page component directly with its data (renderToString(Page(content), …) — the shape the App Initialization example shows); and each emitted page boots through the same hydrate entry as the SSR shell above — with Dehydrated State carrying the build-time data so hydration never refetches.

Semantics worth knowing

  • renderToString gates on the same settlement signal hydrate does: framework-tracked async work (async activity transforms, route pages, lazy imports) serializes; async work outside a transform (a raw fetch in a watch callback, a setTimeout) is invisible to the signal & belongs to the client — boot it with hydrate (see Client hydration) to make the takeover invisible.
  • onCreated, onBeforeRender & onRendered fire as usual; onMounted & onUnmounted never fire on the server — they describe a live, observed browser document.
  • Custom elements need no server-side wiring: each injected window has its own customElements registry, so core replays every defineElement registration into it automatically — including registrations made at module scope, before any window existed.
  • Importing @loom-js/core off-browser is safe - browser-coupled state (router location, history listeners) initializes lazily on first use.
  • Nothing extra ships to the browser: the server entry is a separate export, & the server-side DOM implementation (e.g. linkedom) is your dependency, not loom's.

Client hydration

renderToStringhydrate is the pre-rendering story: the server (or build step) serializes the page, & hydrate boots the client on top of it without ever showing a flash. Where init mounts the app shell immediately — replacing the root's children by default — then churns again as lazy routes & data land, hydrate leaves the pre-rendered DOM untouched while the app renders detached, & performs a single atomic swap once the app has settled — lazy route content & async activity work included. Because server & client run the same render path, the swapped-in DOM matches the served markup & the takeover is invisible.

API

  • hydrate(props): Promise<void> - init's contract minus placement (the swap is always a full replace); resolves after the swap & onAppMounted.
    • props
      • app, root, globalConfig, onAppMounted - As in init.
      • ready?: Promise<unknown> - Optional caller-owned gate: the swap awaits it alongside settlement. Use it for async work the framework cannot track (see the tracking boundary below).
      • maxWait?: number - Upper bound in ms (default 4000) on how long the swap waits. On expiry the swap runs with whatever has rendered & a framework console warning names the still-pending count. Infinity disables the bound.
  • settled(): Promise<void> - The signal hydrate gates on, importable directly: resolves once no framework-mediated async work is pending for the current DOM window, confirmed by one macrotask of continued quiet (so chained lazy work is awaited to quiescence). Useful as a test await point or anywhere "the app is done booting" matters.

Quick Example

import { hydrate } from '@loom-js/core';

import { App } from '@app/app';

// The root already carries the server-rendered markup.
hydrate({
    app: App(),
    root: document.querySelector('#page-content')
});

In a test, that makes the await itself the synchronization - because the transform's promise is tracked, there's no polling loop and no arbitrary sleep:

import { expect } from '@esm-bundle/chai';
import { activity, component, init, settled } from '@loom-js/core';

const greeting = activity('', async ({ input: name, update }) => {
    update(await fetchGreeting(name));
});

const Page = component(
    (html) => html`
        <main>${greeting.effect(({ value }) => value)}</main>
    `
);

it('renders the fetched greeting', async () => {
    init({ app: Page(), root: document.body });
    greetin