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
11 changes: 11 additions & 0 deletions .changeset/olive-pugs-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@solidjs/image": minor
---

Local images now ship an inline preview. The plugin emits a downscaled copy as a data URL plus the dominant color, and the component paints it behind the image until it loads. Turn it off with `placeholder: false`.

`SolidImage` takes a `sizes` prop, which is forwarded to every `source`. Without it the browser assumes the image spans the full viewport width and downloads a larger variant than it needs.

The `fallback` prop is now optional. Leave it out and the image is revealed as soon as it loads.

Processed images are cached. The file name now covers the source file, the format, the width and the quality, and an existing file is reused instead of encoded again. Changing the quality no longer serves a stale image.
36 changes: 28 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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`.
- A tiny preview of the image is inlined in the page and painted behind it, so there is something to look at from the first frame.
- 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 @@ -44,6 +45,7 @@ export default defineConfig({
sizes: [480, 800, 1200],
quality: 80,
publicPath: "public",
placeholder: { size: 20 },
},
}),
],
Expand Down Expand Up @@ -168,8 +170,9 @@ The component does not depend on the plugin. Pass `src` and an optional `transfo
| --- | --- | --- | --- |
| `src` | `SolidImageSource<T>` | yes | The image source, its intrinsic size and any options your transformer needs. |
| `alt` | `string` | yes | Alternative text for the image. |
| `fallback` | `(visible: () => boolean, onLoad: () => void) => JSX.Element` | yes | Placeholder shown while the image loads. See below. |
| `fallback` | `(visible: () => boolean, onLoad: () => void) => JSX.Element` | no | Placeholder shown while the image loads. See below. |
| `transformer` | `SolidImageTransformer<T>` | no | Produces the responsive variants for `src`. |
| `sizes` | `string` | no | Value of the `sizes` attribute, such as `50vw`. |
| `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 @@ -180,7 +183,15 @@ The `fallback` callback receives two arguments.
- `visible` is a signal that is `true` while the placeholder should be shown. It turns `false` once the image has loaded.
- `onLoad` tells the component that your placeholder is on screen. Call it once your placeholder has mounted. The image is only revealed after this call, so the placeholder is never skipped by an image that loads instantly.

The `fallback` only renders on the client, and only after the container has scrolled into view.
The `fallback` only renders on the client, and only after the container has scrolled into view. Leave it out and the image is revealed as soon as it loads.

### Picking the right variant

Width descriptors alone do not tell the browser how wide the image will be on the page, so it assumes the full viewport width and downloads a larger variant than it needs. Pass `sizes` whenever the image is not full width.

```tsx
<SolidImage {...example} alt="example" sizes="(max-width: 600px) 100vw, 50vw" fallback={...} />
```

### Types

Expand All @@ -192,6 +203,11 @@ interface SolidImageSource<T> {
options: T;
}

interface SolidImagePlaceholder {
url: string;
color: string;
}

interface SolidImageVariant {
path: string;
width: number;
Expand Down Expand Up @@ -230,10 +246,13 @@ Handles imports ending in `?image`.
| `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Source formats the plugin will process. Other files are left alone. |
| `output` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Formats to emit. |
| `publicPath` | `string` | `"dist"` | Directory the processed files are written to. |
| `placeholder` | `boolean \| { size?: number }` | `true` | Inline preview of the image. Set a `size` in pixels, or `false` to skip it. |

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`.
Files are written to `<publicPath>/.image/i-<hash>-<width>.<ext>`. 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`.

The hash covers the source path, the size and modification time of the source file, the format, the width and the quality. A file that already exists is left alone, so images are encoded once and reused on later builds and dev server restarts. Editing an image or changing an option produces a new name, so a stale file is never served.

#### `options.remote`

Expand All @@ -243,17 +262,18 @@ Handles imports starting with `image:`.
| --- | --- | --- |
| `transformURL` | `(url: string) => MaybePromise<{ src, variants }>` | Maps the text after `image:` to a source and its variants. |

`src` is `{ source, width, height }`. `variants` is one `SolidImageVariant` or an array of them.
`src` is `{ source, width, height }`, and may carry a `placeholder` of `{ url, color }`. `variants` is one `SolidImageVariant` or an array of them.

Both option groups are optional. Passing neither returns no plugin.

## How it works

1. `SolidImage` renders a container with a padding based aspect ratio box, so the layout is stable before the image arrives.
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.
2. The box is painted with the inline preview and the dominant color of the image, when the source carries a placeholder. The preview is a few pixels wide, so the browser scales it up into a blur.
3. An `IntersectionObserver` watches the container. Nothing loads until it enters the viewport.
4. Once visible, the `<img>` and your placeholder are rendered. The image starts fully transparent.
5. 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 over the preview, and the `onLoad` prop is called.
6. 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.

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
91 changes: 91 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,97 @@ describe("SolidImage in the browser", () => {
expect(sources[0]!.srcset).toBe(`${PIXEL} 400w,${PIXEL} 800w`);
});

it("drops the inline placeholder once the image has loaded", async () => {
const { host, scrollIntoView } = mount(() => (
<SolidImage
src={{
source: PIXEL,
width: 100,
height: 100,
options: {},
placeholder: { url: PIXEL, color: "#336699" },
}}
alt="pixel"
fallback={(visible, show) => (
<Show when={visible()}>
<Placeholder show={show} />
</Show>
)}
/>
));

const box = host.querySelector<HTMLElement>('[data-solid-image="aspect-ratio"]')!;

// The preview is painted before anything is fetched.
expect(box.style.backgroundImage).toContain(PIXEL);
expect(box.style.backgroundColor).toBe("rgb(51, 102, 153)");

scrollIntoView();

await expect.poll(() => findImage(host)?.style.opacity).toBe("1");

expect(box.style.backgroundImage).toBe("");
});

it("keeps the inline placeholder while the image is still loading", async () => {
const { host } = mount(() => (
<SolidImage
src={{
source: PIXEL,
width: 100,
height: 100,
options: {},
placeholder: { url: PIXEL, color: "#336699" },
}}
alt="pixel"
// This placeholder never calls show, so the image is never revealed.
fallback={() => <div data-test="placeholder">Loading...</div>}
/>
));

await new Promise(resolve => setTimeout(resolve, 100));

const box = host.querySelector<HTMLElement>('[data-solid-image="aspect-ratio"]')!;
expect(box.style.backgroundImage).toContain(PIXEL);
});

it("reveals the image with no fallback at all", async () => {
const { host, scrollIntoView } = mount(() => (
<SolidImage src={{ source: PIXEL, width: 100, height: 100, options: {} }} alt="pixel" />
));

scrollIntoView();

await expect.poll(() => findImage(host)?.style.opacity).toBe("1");
});

it("passes sizes to the browser so it picks a variant", async () => {
const { host, scrollIntoView } = mount(() => (
<SolidImage
src={{ source: PIXEL, width: 1600, height: 900, options: {} }}
alt="pixel"
sizes="50vw"
transformer={{
transform: () => [
{ path: PIXEL, width: 400, type: "image/webp" },
{ path: PIXEL, width: 800, type: "image/webp" },
],
}}
fallback={(visible, show) => (
<Show when={visible()}>
<Placeholder show={show} />
</Show>
)}
/>
));

scrollIntoView();

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

expect(host.querySelector("source")!.sizes).toBe("50vw");
});

it("reserves the aspect ratio before the image loads", () => {
const { host } = mount(() => (
<SolidImage
Expand Down
78 changes: 78 additions & 0 deletions src/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,84 @@ describe("SolidImage SSR", () => {
expect(html).toContain(encodeURIComponent('width="800"'));
});

it("renders without a fallback", () => {
const html = renderToString(() => (
<SolidImage
src={{ source: "/hero.png", width: 100, height: 100, options: {} }}
alt="hero"
/>
));

expect(html).toContain('data-solid-image="container"');
expect(html).toContain('data-solid-image="blocker"');
});

it("puts the sizes attribute on every source", () => {
const html = renderToString(() => (
<SolidImage
src={{ source: "/hero.png", width: 1600, height: 900, options: {} }}
alt="hero"
sizes="(max-width: 600px) 100vw, 50vw"
transformer={{
transform: () => [
{ path: "/hero-400.webp", width: 400, type: "image/webp" },
{ path: "/hero-400.jpg", width: 400, type: "image/jpeg" },
],
}}
fallback={() => <div>loading</div>}
/>
));

expect([...html.matchAll(/sizes="\(max-width: 600px\) 100vw, 50vw"/g)]).toHaveLength(2);
});

it("omits the sizes attribute when no value is given", () => {
const html = renderToString(() => (
<SolidImage
src={{ source: "/hero.png", width: 1600, height: 900, options: {} }}
alt="hero"
transformer={{
transform: () => [{ path: "/hero-400.webp", width: 400, type: "image/webp" }],
}}
fallback={() => <div>loading</div>}
/>
));

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

it("paints the inline placeholder behind the image", () => {
const html = renderToString(() => (
<SolidImage
src={{
source: "/hero.png",
width: 1600,
height: 900,
options: {},
placeholder: { url: "data:image/webp;base64,AAA", color: "#336699" },
}}
alt="hero"
fallback={() => <div>loading</div>}
/>
));

expect(html).toContain("background-color:#336699");
expect(html).toContain("background-image:url(&quot;data:image/webp;base64,AAA&quot;)");
expect(html).toContain("background-size:cover");
});

it("renders no placeholder background when the source has none", () => {
const html = renderToString(() => (
<SolidImage
src={{ source: "/hero.png", width: 1600, height: 900, options: {} }}
alt="hero"
fallback={() => <div>loading</div>}
/>
));

expect(html).not.toContain("background-image");
});

it("renders one <source> per MIME type with a srcset", () => {
const html = renderToString(() => (
<SolidImage
Expand Down
91 changes: 91 additions & 0 deletions src/__tests__/vite-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,47 @@ describe("local images", () => {
expect(code).toContain('import source from "./photo.png"');
});

it("inlines a placeholder preview and the dominant color", async () => {
const plugin = createLocalPlugin();
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-source"));

const placeholder = JSON.parse(/placeholder: (\{.+\}),/.exec(code)![1]!);

expect(placeholder.url.startsWith("data:image/webp;base64,")).toBe(true);

// sharp picks the dominant color from a quantized histogram, so it lands
// near the fill color rather than exactly on it.
expect(placeholder.color).toMatch(/^#[0-9a-f]{6}$/);
const channels = [1, 3, 5].map(at => parseInt(placeholder.color.slice(at, at + 2), 16));
for (const [index, expected] of [0x33, 0x66, 0x99].entries()) {
expect(Math.abs(channels[index]! - expected)).toBeLessThan(16);
}

const preview = Buffer.from(placeholder.url.split(",")[1]!, "base64");
const meta = await sharp(preview).metadata();

expect(meta.format).toBe("webp");
expect(meta.width).toBe(20);
expect(preview.byteLength).toBeLessThan(1024);
});

it("uses the configured placeholder size", async () => {
const plugin = createLocalPlugin({ placeholder: { size: 8 } });
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-source"));

const placeholder = JSON.parse(/placeholder: (\{.+\}),/.exec(code)![1]!);
const meta = await sharp(Buffer.from(placeholder.url.split(",")[1]!, "base64")).metadata();

expect(meta.width).toBe(8);
});

it("skips the placeholder when it is turned off", async () => {
const plugin = createLocalPlugin({ placeholder: false });
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-source"));

expect(code).toContain("placeholder: undefined");
});

it("loads a transformer that imports one variant per format and size", async () => {
const plugin = createLocalPlugin();
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-transformer"));
Expand Down Expand Up @@ -264,6 +305,56 @@ describe("local images", () => {
expect(first).toBe(second);
});

it("reuses the file it already emitted instead of encoding again", async () => {
const plugin = createLocalPlugin();
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-800"));
const emitted = path.join(publicPath, /export default "(.+)"/.exec(code)![1]!);

const before = await fs.stat(emitted);
await fs.writeFile(emitted, "not an image");
await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-800"));
const after = await fs.readFile(emitted, "utf8");

// The plugin left the file alone, so nothing was encoded a second time.
expect(after).toBe("not an image");

await fs.rm(emitted);
await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-800"));

expect((await fs.stat(emitted)).size).toBe(before.size);
});

it("gives a different file name when the quality changes", async () => {
const first: string = await callLoad(
createLocalPlugin({ quality: 80 }),
path.join(dir, "photo.png?image-raw-webp-400"),
);
const second: string = await callLoad(
createLocalPlugin({ quality: 20 }),
path.join(dir, "photo.png?image-raw-webp-400"),
);

expect(first).not.toBe(second);
});

it("gives a different file name when the source image changes", async () => {
const editedPath = path.join(dir, "edited.png");
await sharp({ create: { width: 64, height: 32, channels: 3, background: "#336699" } })
.png()
.toFile(editedPath);

const plugin = createLocalPlugin();
const first: string = await callLoad(plugin, path.join(dir, "edited.png?image-raw-webp-400"));

await sharp({ create: { width: 64, height: 32, channels: 3, background: "#993366" } })
.png()
.toFile(editedPath);

const second: string = await callLoad(plugin, path.join(dir, "edited.png?image-raw-webp-400"));

expect(first).not.toBe(second);
});

it("ignores a file extension that is not in the input list", async () => {
const plugin = createLocalPlugin({ input: ["jpeg"] });

Expand Down
Loading
Loading