[]previous and next page

Components

A component is a function of props that returns something insert accepts: a node, text, an array, or an accessor.

For agentsUpdated

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.

src/repeat.tsxtsx
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 });
}
repeat-ssr.tsxtsx
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, ""));
Expected output
<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:

src/teleport.tsxtsx
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;
}
teleport-ssr.tsxtsx
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, ""));
Expected output
<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:

src/external-link.tsxtsx
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.