Skip to content
Merged
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.
39 changes: 30 additions & 9 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 aspect ratio, so the page does not shift while the image loads.
- The image loads once it scrolls into view.
- 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 placeholder shows until the image is ready.
- The Vite plugin resizes and reformats local images at build time.
- Remote images go through your own URL mapping, so a CDN can serve the variants.
Expand Down Expand Up @@ -39,6 +40,7 @@ export default defineConfig({
sizes: [480, 800, 1200],
quality: 80,
publicPath: "public",
placeholder: { size: 20 },
},
}),
],
Expand Down Expand Up @@ -155,8 +157,9 @@ The component works on its own. Pass `src` and an optional `transformer`:
| --- | --- | --- | --- |
| `src` | `SolidImageSource<T>` | yes | The image, its intrinsic size and any options your transformer needs. |
| `alt` | `string` | yes | Alternative text. |
| `fallback` | `(visible: () => boolean, onLoad: () => void) => JSX.Element` | yes | Placeholder shown while the image loads. |
| `fallback` | `(visible: () => boolean, onLoad: () => void) => JSX.Element` | no | Placeholder shown while the image loads. |
| `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 @@ -167,7 +170,15 @@ The `fallback` callback takes two arguments.
- `visible` is a signal. It is `true` while the placeholder should be shown, and `false` once the image has loaded.
- `onLoad` tells the component your placeholder is on screen. Call it once the placeholder has mounted. The image is only revealed after that call, so an image that loads instantly never skips the placeholder.

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

### Picking the right variant

Width descriptors do not tell the browser how wide the image will be on the page. 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 @@ -179,6 +190,11 @@ interface SolidImageSource<T> {
options: T;
}

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

interface SolidImageVariant {
path: string;
width: number;
Expand Down Expand Up @@ -220,10 +236,14 @@ Handles imports ending in `?image`.
| `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Source formats to 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. `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image.
- Files are written to `<publicPath>/.image/i-<hash>-<width>.<ext>`, and the module exports the URL `/.image/i-<hash>-<width>.<ext>`. The hash is an xxHash32 of the source path.
- Files are written to `<publicPath>/.image/i-<hash>-<width>.<ext>`, and the module exports the URL `/.image/i-<hash>-<width>.<ext>`.
- `publicPath` should be served at the root of your site. Add `.image` to `.gitignore` when it sits 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 @@ -233,16 +253,17 @@ 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.

## How it works

1. `SolidImage` renders 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 render. The image starts transparent.
4. Your placeholder calls `onLoad` to say it is on screen.
5. When the image finishes loading after that call, the placeholder is hidden, the image fades in, and the `onLoad` prop fires.
6. On the server the `<img>` carries a blank SVG of the same size, so nothing is fetched before the image 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, 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 render. The image starts transparent.
5. Your placeholder calls `onLoad` to say it is on screen.
6. When the image finishes loading after that call, the placeholder is hidden, the image fades in over the preview, and the `onLoad` prop fires.
7. On the server the `<img>` carries a blank SVG of the same size, so nothing is fetched before the image is in view. The placeholder and the loading logic are client only.

Every rendered element carries 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