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
17 changes: 17 additions & 0 deletions .changeset/lucky-moons-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@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.

Encoded images are now cached between builds in the Vite cache directory, so a build only encodes images that changed.

`sizes` now reaches the `img` as well as every `source`, since the `img` carries its own `srcset`.

`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.
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
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.
- The image loads once it scrolls into view. Mark the image above the fold as `eager` and it loads right away.
- 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.
- Readers with no JavaScript still get the image.
- 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 @@ -159,6 +160,7 @@ The component works on its own. Pass `src` and an optional `transformer`:
| `alt` | `string` | yes | Alternative text. |
| `fallback` | `(visible: () => boolean, onLoad: () => void) => JSX.Element` | no | Placeholder shown while the image loads. |
| `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. |
| `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>`. |
Expand All @@ -180,6 +182,16 @@ Width descriptors do not tell the browser how wide the image will be on the page
<SolidImage {...example} alt="example" sizes="(max-width: 600px) 100vw, 50vw" fallback={...} />
```

### 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 @@ -215,6 +227,7 @@ Notes on the shape:
- `width` and `height` are the intrinsic pixel size. They only reserve the aspect ratio box, so any pair with the right ratio works.
- Variants are grouped by `type`, and each group becomes one `<source>` with a merged `srcset`.
- The browser takes the first `<source>` it supports, so 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. Make that group the most widely supported format.
- Without a transformer no `<source>` is rendered, and the browser loads `src.source`.

### `imagePlugin(options)`
Expand All @@ -239,10 +252,12 @@ Handles imports ending in `?image`.
| `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>`.
- On build the files go through the bundler as assets, so `base`, `assetsDir` and the build manifest apply to them. Nothing is written to `publicPath`.
- On the dev server the files are written to `<publicPath>/.image/i-<hash>-<width>.<ext>` and served from `/.image/...`.
- `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 `<img>` falls back to the largest size of the last output format. The original file is never imported, so it does not reach the bundle.
- 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.
- An image is encoded once and reused. The dev server reuses the file in `publicPath`. A build reuses its copy in the Vite cache directory.
- Editing an image or changing an option produces a new name, so a stale file is never served.

#### `options.remote`
Expand All @@ -263,7 +278,8 @@ Handles imports starting with `image:`.
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.
7. On the server a lazy `<img>` carries a blank SVG of the same size, so nothing is fetched before the image is in view. An eager `<img>` renders in full. The placeholder and the loading logic are client only.
8. 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.

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
48 changes: 48 additions & 0 deletions src/__tests__/browser/solid-image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,54 @@ describe("SolidImage in the browser", () => {
await expect.poll(() => findImage(host)).not.toBe(null);

expect(host.querySelector("source")!.sizes).toBe("50vw");
// The img has its own srcset, so it needs sizes too.
expect(findImage(host)!.sizes).toBe("50vw");
});

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", () => {
Expand Down
85 changes: 80 additions & 5 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 without a fallback", () => {
Expand Down Expand Up @@ -157,7 +161,7 @@ describe("SolidImage SSR", () => {
/>
));

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

it("omits the sizes attribute when no value is given", () => {
Expand Down Expand Up @@ -223,9 +227,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 @@ -270,6 +272,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
78 changes: 74 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", cacheDir?: string) {
const hook = plugin.configResolved as any;
const fn = typeof hook === "function" ? hook : hook.handler;
fn.call({} as any, { command, cacheDir } 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("inlines a placeholder preview and the dominant color", async () => {
Expand Down Expand Up @@ -290,6 +304,62 @@ describe("local images", () => {
expect(meta.width).toBe(400);
});

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

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("reuses the encoded file from the cache on the next build", async () => {
const cacheDir = path.join(dir, "build-cache");
const plugin = createLocalPlugin();
callConfigResolved(plugin, "build", cacheDir);

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

// The plugin keeps its files in a folder of its own inside the Vite cache directory.
const cachePath = path.join(cacheDir, "solid-image", first.mock.calls[0]![0].name);
await fs.writeFile(cachePath, "cached bytes");

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

// The second build emitted the cached bytes, so nothing was encoded again.
expect(second.mock.calls[0]![0].source.toString()).toBe("cached bytes");
});

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", path.join(dir, "cache"));

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