[]previous and next page

Images, scripts and islands

Responsive images with an optimizer, scripts with loading strategies, a nonce and callbacks, and islands that hydrate server markup on a directive.

For agentsUpdated

Use Image, Script and Island to control how a page loads media and browser code. The components share server and browser markup, and each is imported independently.

Start with Images and islands.

Image#

<Image> takes the intrinsic size of the file. It computes the candidate widths, a lazy or priority load, and the box that keeps the aspect ratio before the file arrives, so the page does not move when the image loads.

src/routes/index.tsxtsx
import { Image } from "@shadow-js/dusk/components";

export default function Home() {
  return (
    <Image src="/hero.jpg" width={1200} height={630} alt="The product on a desk" sizes="100vw" priority />
  );
}
  • priority loads now, with a high fetch priority, and preloads the image in the head.
  • fill covers the nearest positioned parent (position: relative on it). width and height then give the candidates and the aspect ratio only.
  • placeholder="blur" with a blurDataURL (a data:image/*;base64 URL of up to 8 KiB), or placeholder="color" with a placeholderColor, is shown until the file arrives. Other values are refused.
  • art gives other crops for other screens: each entry is a <source> of a <picture> with its media query, its size and its format.
  • unoptimized serves src exactly as given (no candidates, no optimizer). A priority unoptimized image preloads its own file.
  • quality (1 to 100), format and loader are passed to the candidate builder. A loader replaces the optimizer's URLs, for a CDN that resizes on its own.
src/routes/products.tsxtsx
import { Image } from "@shadow-js/dusk/components";

export function Banner() {
  return (
    <div style={{ position: "relative", height: "320px" }}>
      <Image
        src="/banner-wide.jpg"
        width={2400}
        height={800}
        alt=""
        fill
        placeholder="color"
        placeholderColor="#1d2b36"
        art={[{ media: "(max-width: 640px)", src: "/banner-phone.jpg", width: 800, height: 1000, format: "webp" }]}
      />
    </div>
  );
}

The optimizer#

<Image> asks /_dusk/image?url=…&w=…&q=…&f=…. The endpoint serves the app's own files (source) and the origins the config allows, never anything else. Without a processor it serves the source unchanged. To resize and convert, give it a processor. sharpProcessor uses sharp, an optional peer: install it in the app (npm install sharp) to use it.

src/entry-server.tsts
import { sharpProcessor } from "@shadow-js/dusk/server";

export default {
  security: { allowedHosts: ["example.com"] },
  images: { processor: sharpProcessor() },
};

The formats, qualities, widths and origins the optimizer may serve are set in dusk.config.ts:

dusk.config.tsts
import { defineConfig } from "@shadow-js/dusk/vite";

export default defineConfig({
  image: {
    domains: ["cdn.example"],
    remotePatterns: ["https://images.example"],
    widths: { deviceSizes: [640, 1200, 1920] },
    formats: ["webp", "avif", "original"],
    qualities: [60, 75, 85],
  },
});

Only the widths the app offers are served. A display width that is not one of them is snapped up to the next offered width (the largest when none covers it), so <Image width={300}> asks for 384 and a 2x screen for 640, and never for 300, which the endpoint would refuse. Responsive images (sizes with vw, and fill) offer every device width from the smallest up. The default lists are 32 to 384 (imageSizes, icons and thumbnails) and 640 to 3840 (deviceSizes). The same list is checked when dusk.config.ts is built, and the server and the browser read it, so the markup and the endpoint always agree.

Under a mounted app (base: "/app") the optimizer is at /app/_dusk/image: the build gives the server and the browser the mount point, and every URL <Image> writes carries it. A custom loader still builds its own URLs.

What the endpoint does with a request:

  • Without f, the format follows Accept: AVIF when the client takes it, else WebP, else the source's own. The answer says Vary: Accept.
  • Each answer has Content-Disposition: inline, a strong ETag (a hash of the bytes), nosniff, and the cache lifetime of cacheControl. A repeated If-None-Match is a 304.
  • An image is never enlarged: a width above the source keeps the source's width.
  • An SVG is served as it is, sandboxed (it can run script). A source the processor does not encode (GIF, TIFF, f=original) is served at its own size.
  • A remote source is fetched only from an allowed origin, and an upstream redirect is a 502: the endpoint does not follow redirects, so a redirect cannot send it to another host. On Node the endpoint also refuses a name that resolves to a private, loopback, link-local or metadata address, at the moment it connects (safeImageFetch, the default there); on Bun, Deno and Workers the platform's egress rules own that check. Keep the origins you allow under your own control.
  • A source over 25 megapixels once decoded (a small file can declare far more) is a 413 with the sharp processor, and the query takes exactly url, w, q and f, each once, w and q as plain digits (anything else is a 400).
  • A corrupt file or a processor failure is a 500 that names no detail; the error goes to onError.

Prepared images#

The optimizer makes a file the first time a browser asks for it. imageVariants() of @shadow-js/dusk/vite makes the files at build time instead: for each image of public/ that your source mentions, it writes AVIF and WebP files at a width ladder (640, 960, 1280 and 1920 by default) into the build's assets folder, and <Image> writes a <picture> whose sources point at them. The optimizer is then the fallback, not the default: the <img> inside the <picture> still points at it, for a browser that reads no source and for every image the plugin did not prepare.

vite.config.tsts
import { dusk, imageVariants } from "@shadow-js/dusk/vite";

export default {
  plugins: [
    dusk(),
    imageVariants({
      widths: [480, 960, 1440],
      formats: ["avif", "webp"],
      quality: { avif: 50, webp: 75 },
      include: ["images/gallery/**"],
      maxOriginalBytes: 512 * 1024,
    }),
  ],
};
  • An image is prepared when a source file (src/**, index.html) holds its path as a string ("/images/hero.jpg"), or when include matches it. Only .jpg, .jpeg, .png and .webp files are read; a file under minBytes (4096) is left alone.
  • The widths are every configured width below the image's own, and the image's own width when it is not above the largest configured one, so a small image still gets the lighter formats. The files are never enlarged.
  • The names are <name>-<width>w-<hash>.<format>, where the hash is the file's content. They are written under <assets folder>/img/ (directory changes the last part), which adapters serve with the year-long immutable lifetime.
  • The plugin adds one import to every module that imports @shadow-js/dusk/components; it calls configureImageVariants with the list of prepared files (called again, it adds to what it has), in the server bundle and in the browser bundle, so a client render writes the same markup as the server. An app that does not use the plugin pays only for the hook inside <Image>, a few bytes.
  • sharp is an optional peer. Without it the build prints one warning and serves the images as they are. The finished files are kept in Vite's cache folder (cacheDir), keyed by the source bytes and the options, so an unchanged image is not encoded again.
  • An original over maxOriginalBytes (1 MiB by default, 0 turns it off) is named in a build warning.
  • <Image> uses the prepared files for a src that was prepared, unless the props ask for something else: unoptimized, loader, format and art keep their own behavior. A priority image preloads the best prepared format, with a type, so a browser that cannot decode it skips the preload.
  • An image imported from source (import hero from "./hero.jpg") is prepared too. The bundler gives it its final name (/assets/hero-AbCd1234.jpg, or the CDN URL when the build has an assetOrigin), and the plugin reads the bytes from the bundle then, makes the same ladder of files, and keys the map by that URL. The variants are named from the original file (hero-960w-<hash>.avif) and sit under the same assets folder, on the CDN when the original is. An image the bundler inlines as a data URL (under 4 KiB by default) is not prepared, and neither is a file the code only references by a ?url or ?raw query.

Script#

<Script> writes a script with the request's nonce, or runs one in the browser when its strategy says so.

src/routes/_layout.tsxtsx
import { Script } from "@shadow-js/dusk/components";

export default function Layout(props: { children?: unknown }) {
  return (
    <>
      <Script src="/polyfills.js" strategy="beforeInteractive" />
      <Script src="https://analytics.example/a.js" id="analytics" onReady={() => console.log("analytics ready")} />
      <Script src="/chat.js" strategy="lazyOnload" onLoad={() => console.log("chat loaded")} onError={(error) => console.error(error)} />
      {props.children}
    </>
  );
}
  • beforeInteractive blocks parsing (the first code the page needs). afterInteractive (the default) loads async as soon as possible. lazyOnload is not in the HTML: the browser loads it after the window's load event.
  • id (default: the src) is the key. A script with a key is written once per response, however many components name it.
  • nonce defaults to the request's nonce on the server. A nonce that is not a token is refused.
  • onLoad, onError and onReady run in the browser. onReady runs after onLoad, or at once when the script is already there. Inline code (children) must not contain </script or <!--.

On the server the callbacks do not run. For a script the server wrote, the browser may already have loaded it before the page hydrates, so onLoad then runs when the page can see the element.

Island#

<Island> renders its children on the server. The browser takes the island over when its directive fires, with the component the registry names. The props are plain data, so they can be written into the page.

src/routes/index.tsxtsx
import { Island } from "@shadow-js/dusk/components";

export default function Home() {
  return (
    <section>
      <Island id="counter" client:load props={{ start: 3 }}>
        <p>3 items</p>
      </Island>
      <Island id="map" client:visible props={{ center: [51.5, -0.1] }}>
        <p>A map of the offices</p>
      </Island>
      <Island id="editor" client:only fallback={<p>Loading the editor</p>} />
    </section>
  );
}

The directives: client:load (as soon as the page is interactive), client:idle, client:visible, client:interaction (the first pointer, key or focus), client:media="(min-width: 800px)", and client:only (no server markup: the fallback is shown until the browser mounts the component). Give one; with none the island is client:visible.

The browser side names each island in a registry. Each entry is a loader, so an island is its own chunk:

src/entry-client.tsts
import { hydrateIslands } from "@shadow-js/dusk/client";

hydrateIslands({
  registry: {
    counter: () => import("./islands/counter"),
    map: () => import("./islands/map"),
    editor: () => import("./islands/editor"),
  },
});
src/islands/counter.tsxtsx
export default function Counter(props: { start: number }) {
  return <p>{props.start} items</p>;
}
src/islands/map.tsxtsx
export default function Map(props: { center: readonly number[] }) {
  return <p>A map of the offices at {props.center.join(", ")}</p>;
}
src/islands/editor.tsxtsx
export default function Editor() {
  return <textarea aria-label="Notes" />;
}

The component an island loads must render the markup the server wrote, because the browser claims it. client:only islands are rendered, not hydrated.

Limits#

  • <Image> imports the head module for its priority preload, so a minimal page that uses it ships about 15 KB of JavaScript (gzip) in total, against about 8 KB for a minimal page that uses only <Script>.
  • The optimizer runs in Node with sharp. Runtimes without it (Workers, Deno Deploy) need another processor, or serve the source; an adapter can provide one.
  • There is no WebAssembly codec in the package. The processor interface is public, so one can be written.