[]previous and next page

Images, scripts, fonts and islands

These are plain functions and data, so they run the same way on the server and in the browser.

For agentsUpdated

These are plain functions and data, so they run the same way on the server and in the browser. They are not components: a Dusk source file is compiled without JSX, so the markup is written with the spread syntax of JSX (<img {...} />, <div {...} />). That keeps the server and the browser output equal, which is what hydration needs.

Images. imageProps returns the attributes of an <img>: a srcset of the display width and each offered width up to twice it, lazy loading (or eager with a high fetch priority for priority: true), decoding="async", and an aspect-ratio from the width and height, so the box keeps its shape before the file arrives. The candidates point at /_dusk/image, the optimizer endpoint:

src/components/hero.tsts
import { imageProps, preloadImageTag } from "@shadow-js/dusk";

export const hero = imageProps({ src: "/hero.jpg", width: 1200, height: 630, alt: "", priority: true, sizes: "100vw" });
export const heroPreload = preloadImageTag({ src: "/hero.jpg", width: 1200, height: 630, alt: "", sizes: "100vw" });

The optimizer answers GET /_dusk/image?url=&w=&q=&f= for the widths the app offers only: IMAGE_WIDTHS by default, and images.widths (or image.widths in dusk.config.ts) changes them. imageProps snaps every width it writes to one of them. It serves a file of the app through the images.source option, and a remote image only from an origin in images.remotePatterns (no redirects, a size limit, a timeout). Its processor is pluggable: the built-in passthroughProcessor changes nothing, and svgProcessor passes SVG through (vector) and refuses raster sources. An SVG is served with a sandboxing Content Security Policy, because it can run script. The package ships no native image code: an adapter or an app that can resize supplies an ImageProcessor.

Scripts. scriptTag returns the markup of a script for its strategy: beforeInteractive (blocking, in the head), afterInteractive (async), or lazyOnload (nothing in the markup; loadScript loads it from the browser after the load event). Each script carries the request's nonce, so a policy with script-src 'nonce-…' allows it and no other script. The head registry refuses <script>, so the markup goes through the document.head option, which receives the request's event:

src/server/document.tsts
import { scriptsHtml } from "@shadow-js/dusk";
import type { RequestEvent } from "@shadow-js/dusk/server";

export const head = (event: RequestEvent): string =>
  scriptsHtml([{ src: "/polyfill.js", strategy: "beforeInteractive" }, { src: "https://cdn.example/analytics.js" }], event.nonce);

Fonts. fontPreloadTag is the head link that starts a font's download early. fontFace writes the @font-face rule, with font-display: swap by default; family names and URLs that could break out of the CSS are refused. fallbackFontFace describes a local font that stands in while the web font loads, scaled (size-adjust) and given the web font's ascent, descent and line gap, so the text does not jump when the web font arrives. Its inputs are the two fonts' metrics: xWidthAvg is the average width of a lowercase letter, measured from the font, not guessed.

src/styles/fonts.tsts
import { fallbackFontFace, fontFace, fontPreloadTag } from "@shadow-js/dusk";

export const inter = fontFace({
  family: "Inter",
  src: [{ url: "/fonts/inter.woff2", format: "woff2" }],
  weight: "100 900",
  display: "swap",
});
export const interFallback = fallbackFontFace({
  family: "Inter fallback",
  local: "Arial",
  font: { unitsPerEm: 2816, xWidthAvg: 1100, ascender: 2728, descender: -680, lineGap: 0 },
  fallback: { unitsPerEm: 2048, xWidthAvg: 904 },
});
export const interPreload = fontPreloadTag({ href: "/fonts/inter.woff2" });

Islands. A page part that does not need JavaScript on first paint is rendered by the server and hydrated later. Spread islandAttributes on the element: its id, its strategy (visible, the default; idle; interaction; media with a query), and its props, which must be plain data (functions, class instances, bigint, NaN and cycles are refused, and <, >, & are escaped in the attribute). In the browser, hydrateIslands mounts each island when its strategy fires. The registry maps each id to a loader; a loader written as () => import("./chart") gives the island a chunk of its own, which the browser downloads only when the island mounts:

src/components/chart-panel.tsts
import { islandAttributes } from "@shadow-js/dusk";

export const panel = islandAttributes({ id: "chart", client: "interaction", props: { series: [3, 1, 4], title: "Sales" } });
src/chart.tsts
export default function Chart(props: Readonly<Record<string, unknown>>): unknown {
  return props["title"];
}
src/entry-client.tsts
import { hydrateIslands } from "@shadow-js/dusk/client";

const islands = hydrateIslands({
  registry: { chart: () => import("./chart") },
  onError: (error) => console.error(error),
});
void islands.hydrated;

An island inside another island is left to the outer one. The island's own markup is replaced by its component's output when it mounts (there is no DOM reuse in this version: the server's markup is what the page shows until then).