Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/lucky-moons-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@solidjs/image": minor
---

The `img` now carries a `srcset` of the last output format, so a browser that supports none of the `source` formats still picks a sized variant. It used to fall back to the full size original.

The original image is no longer imported. `src.source` points at the largest variant of the fallback format, so the untouched original never reaches the bundle.

Processed images go through the bundler on build, so `base`, `assetsDir` and the build manifest now apply to them. The dev server still writes them to the public directory.

`SolidImage` takes an `eager` prop for the image above the fold. It loads right away instead of waiting for the observer, and the server renders it in full so the browser finds it while parsing the page.

Readers with no JavaScript now get the image. The server renders a `noscript` copy alongside the lazy one.
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Optimized image components and Vite tooling for [Solid](https://solidjs.com).

- `SolidImage` renders a responsive `<picture>` that reserves the image's aspect ratio, so the page does not shift while the image loads.
- The image only loads once it scrolls into view, using `IntersectionObserver`.
- The image only loads once it scrolls into view, using `IntersectionObserver`. Mark the image above the fold as `eager` and it loads right away.
- Readers with no JavaScript still get the image.
- Your own placeholder is rendered while the image loads, and fades out when the image is ready.
- The Vite plugin turns a local image import into a set of resized and reformatted files at build time.
- Remote images go through your own URL mapping, so a CDN can serve the variants instead.
Expand Down Expand Up @@ -170,6 +171,7 @@ The component does not depend on the plugin. Pass `src` and an optional `transfo
| `alt` | `string` | yes | Alternative text for the image. |
| `fallback` | `(visible: () => boolean, onLoad: () => void) => JSX.Element` | yes | Placeholder shown while the image loads. See below. |
| `transformer` | `SolidImageTransformer<T>` | no | Produces the responsive variants for `src`. |
| `eager` | `boolean` | no | Loads the image right away instead of waiting for it to scroll into view. |
| `onLoad` | `() => void` | no | Called once the image has loaded and the placeholder is hidden. |
| `crossOrigin` | `JSX.HTMLCrossorigin` | no | Forwarded to the `<img>`. |
| `fetchPriority` | `"high" \| "low" \| "auto"` | no | Forwarded to the `<img>`. |
Expand All @@ -182,6 +184,16 @@ The `fallback` callback receives two arguments.

The `fallback` only renders on the client, and only after the container has scrolled into view.

### Above the fold

Lazy loading costs time for the first image on the page, because nothing starts until the observer reports. Mark that one image as `eager`.

```tsx
<SolidImage {...example} alt="example" eager fetchPriority="high" fallback={...} />
```

The server then renders the real image instead of a blank placeholder, so the browser finds it while it parses the page. Leave every other image lazy.

### Types

```ts
Expand Down Expand Up @@ -211,6 +223,8 @@ interface SolidImageTransformer<T> {

Variants are grouped by `type` and merged into one `srcset` per group. The browser picks the first `<source>` whose type it supports, then picks a width from the `srcset`. Order your output formats from most to least preferred.

The `<img>` carries the last group as its own `srcset`, for a browser that supports none of the formats above it. That group should be the most widely supported format, which is why the order matters.

The transformer is optional. Without one, no `<source>` is rendered and the browser loads `src.source` directly.

### `imagePlugin(options)`
Expand All @@ -233,7 +247,11 @@ Handles imports ending in `?image`.

One file is emitted per output format and per size, so `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image.

Files are written to `<publicPath>/.image/i-<hash>-<width>.<ext>`, where the hash is an xxHash32 of the source path. The module exports the public URL `/.image/i-<hash>-<width>.<ext>`, so `publicPath` should be a directory that is served at the root of your site. Add `.image` to `.gitignore` if it lives inside a checked in directory such as `public`.
On build the files go through the bundler as assets, so `base`, `assetsDir` and the build manifest apply to them like any other asset. Nothing is written to `publicPath`.

On the dev server the files are written to `<publicPath>/.image/i-<hash>-<width>.<ext>` and served from `/.image/...`, so `publicPath` should be a directory that is served at the root of your site. Add `.image` to `.gitignore` if it lives inside a checked in directory such as `public`.

The image the `<img>` falls back to is the largest size of the last output format. The original file is never imported, so it does not reach the bundle.

#### `options.remote`

Expand All @@ -253,7 +271,8 @@ Both option groups are optional. Passing neither returns no plugin.
2. An `IntersectionObserver` watches the container. Nothing loads until it enters the viewport.
3. Once visible, the `<img>` and your placeholder are rendered. The image starts fully transparent.
4. Your placeholder calls `onLoad` to say it is on screen. When the image finishes loading after that, the placeholder is hidden, the image fades in and the `onLoad` prop is called.
5. On the server, the `<img>` renders with a blank SVG of the same size, so the browser does not fetch the image before it is in view. The placeholder and the loading logic are client only.
5. On the server, a lazy `<img>` renders with a blank SVG of the same size, so the browser does not fetch the image before it is in view. An eager `<img>` renders in full. The placeholder and the loading logic are client only.
6. The server also renders a `<noscript>` copy of the image, so a reader with no JavaScript sees it. Browsers never load the content of a `<noscript>` element, so it costs nothing otherwise.

The rendered elements carry a `data-solid-image` attribute you can style. The values are `container`, `aspect-ratio`, `picture`, `image` and `blocker`. The shipped stylesheet uses the same attribute.

Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/browser/solid-image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,52 @@ describe("SolidImage in the browser", () => {
expect(sources[0]!.srcset).toBe(`${PIXEL} 400w,${PIXEL} 800w`);
});

it("loads an eager image without waiting for it to scroll into view", async () => {
const { host } = mount(() => (
<SolidImage
src={{ source: PIXEL, width: 100, height: 100, options: {} }}
alt="pixel"
eager
fallback={(visible, show) => (
<Show when={visible()}>
<Placeholder show={show} />
</Show>
)}
/>
));

// Never scrolled into view, so only `eager` can render this.
await expect.poll(() => findImage(host)?.getAttribute("src")).toBe(PIXEL);
await expect.poll(() => findImage(host)?.style.opacity).toBe("1");
});

it("gives the img a srcset the browser can pick from", async () => {
const { host, scrollIntoView } = mount(() => (
<SolidImage
src={{ source: PIXEL, width: 1600, height: 900, options: {} }}
alt="pixel"
transformer={{
transform: () => [
{ path: PIXEL, width: 400, type: "image/webp" },
{ path: PIXEL, width: 400, type: "image/jpeg" },
{ path: PIXEL, width: 800, type: "image/jpeg" },
],
}}
fallback={(visible, show) => (
<Show when={visible()}>
<Placeholder show={show} />
</Show>
)}
/>
));

scrollIntoView();

await expect.poll(() => findImage(host)).not.toBe(null);

expect(findImage(host)!.srcset).toBe(`${PIXEL} 400w,${PIXEL} 800w`);
});

it("reserves the aspect ratio before the image loads", () => {
const { host } = mount(() => (
<SolidImage
Expand Down
83 changes: 79 additions & 4 deletions src/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,13 @@ describe("SolidImage SSR", () => {

// The server placeholder is a blank SVG of the same size, so the browser
// does not fetch the image before it scrolls into view.
expect(html).not.toContain("hero.png");
expect(html).toContain("data:image/svg+xml,");
expect(html).toContain(encodeURIComponent('width="800"'));

// The real image is only offered to readers with no JavaScript, and a
// browser never loads the content of a noscript element.
const outsideNoscript = html.replace(/<noscript[^>]*>.*?<\/noscript>/gs, "");
expect(outsideNoscript).not.toContain("hero.png");
});

it("renders one <source> per MIME type with a srcset", () => {
Expand All @@ -145,9 +149,7 @@ describe("SolidImage SSR", () => {
/>
));

expect(html).toContain(
'<source data-hk="01000" type="image/webp" srcset="/hero-400.webp 400w,/hero-800.webp 800w">',
);
expect(html).toContain('type="image/webp" srcset="/hero-400.webp 400w,/hero-800.webp 800w"');
expect(html).toContain('type="image/jpeg" srcset="/hero-400.jpg 400w"');
});

Expand Down Expand Up @@ -192,6 +194,79 @@ describe("SolidImage SSR", () => {
expect(html).not.toContain("loading");
});

it("gives the img a srcset from the least preferred format", () => {
const html = renderToString(() => (
<SolidImage
src={{ source: "/hero.png", width: 1600, height: 900, options: {} }}
alt="hero"
eager
transformer={{
transform: () => [
{ path: "/hero-400.webp", width: 400, type: "image/webp" },
{ path: "/hero-400.jpg", width: 400, type: "image/jpeg" },
{ path: "/hero-800.jpg", width: 800, type: "image/jpeg" },
],
}}
fallback={() => <div>loading</div>}
/>
));

// Without this the browser falls back to the full size original.
expect(html).toContain('srcset="/hero-400.jpg 400w,/hero-800.jpg 800w" alt="hero"');
});

it("gives the img no srcset when there is no transformer", () => {
const html = renderToString(() => (
<SolidImage
src={{ source: "/hero.png", width: 100, height: 100, options: {} }}
alt="hero"
eager
fallback={() => <div>loading</div>}
/>
));

expect(html).not.toContain("srcset=");
});

it("renders the real image on the server when it is eager", () => {
const html = renderToString(() => (
<SolidImage
src={{ source: "/hero.png", width: 100, height: 100, options: {} }}
alt="hero"
eager
fetchPriority="high"
fallback={() => <div>loading</div>}
/>
));

const outsideNoscript = html.replace(/<noscript[^>]*>.*?<\/noscript>/gs, "");

// The browser finds the image while it parses the page, instead of waiting
// for the observer to report.
expect(outsideNoscript).toContain('src="/hero.png"');
expect(outsideNoscript).not.toContain("data:image/svg+xml,");
expect(outsideNoscript).toContain('fetchpriority="high"');
});

it("offers the image to readers with no JavaScript", () => {
const html = renderToString(() => (
<SolidImage
src={{ source: "/hero.png", width: 1600, height: 900, options: {} }}
alt="hero"
transformer={{
transform: () => [{ path: "/hero-400.jpg", width: 400, type: "image/jpeg" }],
}}
fallback={() => <div>loading</div>}
/>
));

const noscript = /<noscript[^>]*>(.*?)<\/noscript>/s.exec(html)![1]!;

expect(noscript).toContain('src="/hero.png"');
expect(noscript).toContain('srcset="/hero-400.jpg 400w"');
expect(noscript).toContain('alt="hero"');
});

it("marks the container, aspect ratio box, picture and blocker elements", () => {
const html = renderToString(() => (
<SolidImage
Expand Down
59 changes: 55 additions & 4 deletions src/__tests__/vite-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import sharp from "sharp";
import type { Plugin } from "vite";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { imagePlugin } from "../vite/index";
import type { SolidImageOptions } from "../vite/index";

Expand All @@ -15,10 +15,16 @@ function callResolveId(plugin: Plugin, id: string, importer?: string) {
return fn.call({} as any, id, importer, {});
}

function callLoad(plugin: Plugin, id: string) {
function callLoad(plugin: Plugin, id: string, context: unknown = {}) {
const hook = plugin.load as any;
const fn = typeof hook === "function" ? hook : hook.handler;
return fn.call({} as any, id, {});
return fn.call(context as any, id, {});
}

function callConfigResolved(plugin: Plugin, command: "build" | "serve") {
const hook = plugin.configResolved as any;
const fn = typeof hook === "function" ? hook : hook.handler;
fn.call({} as any, { command } as any);
}

function getPlugin(plugins: Plugin[], name: string): Plugin {
Expand Down Expand Up @@ -207,7 +213,15 @@ describe("local images", () => {

expect(code).toContain("width: 64");
expect(code).toContain("height: 32");
expect(code).toContain('import source from "./photo.png"');
});

it("points the source at the largest variant of the fallback format", async () => {
const plugin = createLocalPlugin({ output: ["webp", "jpeg"], sizes: [400, 800] });
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-source"));

// jpeg is last in the output list, so it is the format every browser reads.
expect(code).toContain('import source from "./photo.png?image-raw-jpeg-800"');
expect(code).not.toContain('import source from "./photo.png"');
});

it("loads a transformer that imports one variant per format and size", async () => {
Expand Down Expand Up @@ -249,6 +263,43 @@ describe("local images", () => {
expect(meta.width).toBe(400);
});

it("emits the file through the bundler on build", async () => {
const plugin = createLocalPlugin();
callConfigResolved(plugin, "build");

const emitFile = vi.fn((_asset: { type: string; name: string; source: Buffer }) => "abc123");
const code: string = await callLoad(
plugin,
path.join(dir, "photo.png?image-raw-webp-400"),
{ emitFile },
);

// Going through the bundler is what makes `base`, `assetsDir` and the
// manifest apply to these files.
expect(code).toBe("export default import.meta.ROLLUP_FILE_URL_abc123;");
expect(emitFile).toHaveBeenCalledTimes(1);

const emitted = emitFile.mock.calls[0]![0];
expect(emitted.type).toBe("asset");
expect(emitted.name).toMatch(/^i-[0-9a-f]+-400\.webp$/);

const meta = await sharp(emitted.source).metadata();
expect(meta.format).toBe("webp");
expect(meta.width).toBe(400);
});

it("does not write to the public directory on build", async () => {
const buildPublicPath = path.join(dir, "build-public");
const plugin = createLocalPlugin({ publicPath: buildPublicPath });
callConfigResolved(plugin, "build");

await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-400"), {
emitFile: () => "abc123",
});

await expect(fs.stat(buildPublicPath)).rejects.toThrow();
});

it("uses the jpg extension for jpeg output", async () => {
const plugin = createLocalPlugin();
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-jpeg-800"));
Expand Down
Loading
Loading