Components
A component is a function of props that returns something insert accepts: a node, text, an array, or an accessor.
Control flow from core hooks: Repeat#
A component is a function of props that returns something insert accepts: a node, text, an array, or an
accessor. Control flow returns an accessor built from a memo, so that the content changes without the
component running again. mapArray owns the rows (each in its own root, disposed when its key goes away), exactly
like For.
import { type Accessor, mapArray } from "@shadow-js/core";
export interface RepeatProps {
readonly times: number;
readonly fallback?: unknown;
/** Called once per row, untracked, in the row's own root. */
readonly children: (index: number) => unknown;
}
/** Renders `children(0)` ... `children(times - 1)`; growing adds rows, shrinking disposes the last ones. */
export function Repeat(props: RepeatProps): Accessor<unknown[]> {
const indexes = (): number[] =>
Array.from({ length: Math.max(0, Math.floor(props.times)) }, (_unused, index) => index);
return mapArray(indexes, (index) => props.children(index), { fallback: () => props.fallback });
}
import { renderToString } from "@shadow-js/web/server";
import { Repeat } from "./src/repeat";
const html = renderToString(() => (
<ul>
<Repeat times={3}>{(index) => <li>row {index}</li>}</Repeat>
</ul>
));
console.log(html.replace(/ data-hk="[^"]*"/g, ""));
<ul><!--[--><li>row <!--[-->0<!--]--></li><li>row <!--[-->1<!--]--></li><li>row <!--[-->2<!--]--></li><!--]--></ul>A component built only from core hooks needs nothing for the server or hydration: the compiler assigns
template keys, and the runtime handles the rest. Reach for @shadow-js/web/extend when you create DOM nodes
yourself or place content somewhere other than where the component sits.
A component on the web extend APIs: Teleport#
insert is a region: a span of children in a parent, kept equal to a value (text, nodes, arrays, accessors),
reusing nodes and moving instead of recreating. createRegion, bindRegion and removeRegion expose it. A
Teleport binds its children as a region of another element:
import { createRoot, isServer, useRenderEffect } from "@shadow-js/core";
import { bindRegion, createRegion, removeRegion } from "@shadow-js/web/extend";
export interface TeleportProps {
/** The element that receives the content: a node, or a function read reactively. */
readonly to: Element | null | (() => Element | null);
readonly children?: unknown;
}
/** Renders `children` into another element, with no wrapper. Renders nothing on the server. */
export function Teleport(props: TeleportProps): null {
if (isServer) return null;
// A render effect runs now, in the render phase, and again when `to` changes.
useRenderEffect(
() => (typeof props.to === "function" ? props.to() : props.to),
(target) => {
if (target === null) return undefined;
const region = createRegion(target, null, undefined);
// Its own root: the content's effects stop with it, and the nodes are removed explicitly.
const stop = createRoot((dispose) => {
bindRegion(region, () => props.children);
return dispose;
});
// The returned function is this run's cleanup: it runs before the next run and on disposal.
return () => {
stop();
removeRegion(region);
};
},
);
return null;
}
import { renderToString } from "@shadow-js/web/server";
import { Teleport } from "./src/teleport";
const html = renderToString(() => (
<div>
<Teleport to={null}>never rendered on the server</Teleport>
</div>
));
console.log(html.replace(/ data-hk="[^"]*"/g, ""));
<div><!--[--><!--]--></div>Teleport is a deliberately small Portal. The built-in Portal additionally keeps node identity when to
changes (it moves the nodes instead of rebuilding them), supports shadow roots and warns when the target is
missing. Read packages/web/src/control-flow/portal.ts: it is written with these same exports.
Reusing the runtime's URL policy#
isUnsafeUrlAttribute(name, value) is the exact rule the runtime applies to dynamic attributes. A component
that accepts a URL can apply it before the runtime does, and report through the same warning code:
import type { JsxChild } from "@shadow-js/web";
import { isUnsafeUrlAttribute, warnOnce } from "@shadow-js/web/extend";
export function ExternalLink(props: { href: string; children?: JsxChild }) {
const unsafe = isUnsafeUrlAttribute("href", props.href);
if (unsafe) warnOnce("UNSAFE_URL_BLOCKED", "ExternalLink refused a script URL.");
return (
<a href={unsafe ? undefined : props.href} rel="noopener noreferrer" target="_blank">
{props.children}
</a>
);
}
warnOnce takes the web codes only (UNSAFE_URL_BLOCKED, INVALID_ATTRIBUTE_NAME) and deduplicates them like
the runtime does.