Skip to content

Commit 3659561

Browse files
committed
fix: emitFile, eager/priority modes, noscript, srcset
1 parent 1654ee5 commit 3659561

7 files changed

Lines changed: 328 additions & 45 deletions

File tree

‎.changeset/lucky-moons-repeat.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@solidjs/image": minor
3+
---
4+
5+
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.
6+
7+
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.
8+
9+
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.
10+
11+
`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.
12+
13+
Readers with no JavaScript now get the image. The server renders a `noscript` copy alongside the lazy one.

‎README.md‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
Optimized image components and Vite tooling for [Solid](https://solidjs.com).
44

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

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

187+
### Above the fold
188+
189+
Lazy loading costs time for the first image on the page, because nothing starts until the observer reports. Mark that one image as `eager`.
190+
191+
```tsx
192+
<SolidImage {...example} alt="example" eager fetchPriority="high" fallback={...} />
193+
```
194+
195+
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.
196+
185197
### Types
186198

187199
```ts
@@ -211,6 +223,8 @@ interface SolidImageTransformer<T> {
211223

212224
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.
213225

226+
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.
227+
214228
The transformer is optional. Without one, no `<source>` is rendered and the browser loads `src.source` directly.
215229

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

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

236-
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`.
250+
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`.
251+
252+
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`.
253+
254+
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.
237255

238256
#### `options.remote`
239257

@@ -253,7 +271,8 @@ Both option groups are optional. Passing neither returns no plugin.
253271
2. An `IntersectionObserver` watches the container. Nothing loads until it enters the viewport.
254272
3. Once visible, the `<img>` and your placeholder are rendered. The image starts fully transparent.
255273
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.
256-
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.
274+
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.
275+
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.
257276

258277
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.
259278

‎src/__tests__/browser/solid-image.test.tsx‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,52 @@ describe("SolidImage in the browser", () => {
170170
expect(sources[0]!.srcset).toBe(`${PIXEL} 400w,${PIXEL} 800w`);
171171
});
172172

173+
it("loads an eager image without waiting for it to scroll into view", async () => {
174+
const { host } = mount(() => (
175+
<SolidImage
176+
src={{ source: PIXEL, width: 100, height: 100, options: {} }}
177+
alt="pixel"
178+
eager
179+
fallback={(visible, show) => (
180+
<Show when={visible()}>
181+
<Placeholder show={show} />
182+
</Show>
183+
)}
184+
/>
185+
));
186+
187+
// Never scrolled into view, so only `eager` can render this.
188+
await expect.poll(() => findImage(host)?.getAttribute("src")).toBe(PIXEL);
189+
await expect.poll(() => findImage(host)?.style.opacity).toBe("1");
190+
});
191+
192+
it("gives the img a srcset the browser can pick from", async () => {
193+
const { host, scrollIntoView } = mount(() => (
194+
<SolidImage
195+
src={{ source: PIXEL, width: 1600, height: 900, options: {} }}
196+
alt="pixel"
197+
transformer={{
198+
transform: () => [
199+
{ path: PIXEL, width: 400, type: "image/webp" },
200+
{ path: PIXEL, width: 400, type: "image/jpeg" },
201+
{ path: PIXEL, width: 800, type: "image/jpeg" },
202+
],
203+
}}
204+
fallback={(visible, show) => (
205+
<Show when={visible()}>
206+
<Placeholder show={show} />
207+
</Show>
208+
)}
209+
/>
210+
));
211+
212+
scrollIntoView();
213+
214+
await expect.poll(() => findImage(host)).not.toBe(null);
215+
216+
expect(findImage(host)!.srcset).toBe(`${PIXEL} 400w,${PIXEL} 800w`);
217+
});
218+
173219
it("reserves the aspect ratio before the image loads", () => {
174220
const { host } = mount(() => (
175221
<SolidImage

‎src/__tests__/components.test.tsx‎

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,13 @@ describe("SolidImage SSR", () => {
124124

125125
// The server placeholder is a blank SVG of the same size, so the browser
126126
// does not fetch the image before it scrolls into view.
127-
expect(html).not.toContain("hero.png");
128127
expect(html).toContain("data:image/svg+xml,");
129128
expect(html).toContain(encodeURIComponent('width="800"'));
129+
130+
// The real image is only offered to readers with no JavaScript, and a
131+
// browser never loads the content of a noscript element.
132+
const outsideNoscript = html.replace(/<noscript[^>]*>.*?<\/noscript>/gs, "");
133+
expect(outsideNoscript).not.toContain("hero.png");
130134
});
131135

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

148-
expect(html).toContain(
149-
'<source data-hk="01000" type="image/webp" srcset="/hero-400.webp 400w,/hero-800.webp 800w">',
150-
);
152+
expect(html).toContain('type="image/webp" srcset="/hero-400.webp 400w,/hero-800.webp 800w"');
151153
expect(html).toContain('type="image/jpeg" srcset="/hero-400.jpg 400w"');
152154
});
153155

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

197+
it("gives the img a srcset from the least preferred format", () => {
198+
const html = renderToString(() => (
199+
<SolidImage
200+
src={{ source: "/hero.png", width: 1600, height: 900, options: {} }}
201+
alt="hero"
202+
eager
203+
transformer={{
204+
transform: () => [
205+
{ path: "/hero-400.webp", width: 400, type: "image/webp" },
206+
{ path: "/hero-400.jpg", width: 400, type: "image/jpeg" },
207+
{ path: "/hero-800.jpg", width: 800, type: "image/jpeg" },
208+
],
209+
}}
210+
fallback={() => <div>loading</div>}
211+
/>
212+
));
213+
214+
// Without this the browser falls back to the full size original.
215+
expect(html).toContain('srcset="/hero-400.jpg 400w,/hero-800.jpg 800w" alt="hero"');
216+
});
217+
218+
it("gives the img no srcset when there is no transformer", () => {
219+
const html = renderToString(() => (
220+
<SolidImage
221+
src={{ source: "/hero.png", width: 100, height: 100, options: {} }}
222+
alt="hero"
223+
eager
224+
fallback={() => <div>loading</div>}
225+
/>
226+
));
227+
228+
expect(html).not.toContain("srcset=");
229+
});
230+
231+
it("renders the real image on the server when it is eager", () => {
232+
const html = renderToString(() => (
233+
<SolidImage
234+
src={{ source: "/hero.png", width: 100, height: 100, options: {} }}
235+
alt="hero"
236+
eager
237+
fetchPriority="high"
238+
fallback={() => <div>loading</div>}
239+
/>
240+
));
241+
242+
const outsideNoscript = html.replace(/<noscript[^>]*>.*?<\/noscript>/gs, "");
243+
244+
// The browser finds the image while it parses the page, instead of waiting
245+
// for the observer to report.
246+
expect(outsideNoscript).toContain('src="/hero.png"');
247+
expect(outsideNoscript).not.toContain("data:image/svg+xml,");
248+
expect(outsideNoscript).toContain('fetchpriority="high"');
249+
});
250+
251+
it("offers the image to readers with no JavaScript", () => {
252+
const html = renderToString(() => (
253+
<SolidImage
254+
src={{ source: "/hero.png", width: 1600, height: 900, options: {} }}
255+
alt="hero"
256+
transformer={{
257+
transform: () => [{ path: "/hero-400.jpg", width: 400, type: "image/jpeg" }],
258+
}}
259+
fallback={() => <div>loading</div>}
260+
/>
261+
));
262+
263+
const noscript = /<noscript[^>]*>(.*?)<\/noscript>/s.exec(html)![1]!;
264+
265+
expect(noscript).toContain('src="/hero.png"');
266+
expect(noscript).toContain('srcset="/hero-400.jpg 400w"');
267+
expect(noscript).toContain('alt="hero"');
268+
});
269+
195270
it("marks the container, aspect ratio box, picture and blocker elements", () => {
196271
const html = renderToString(() => (
197272
<SolidImage

‎src/__tests__/vite-plugin.test.ts‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import os from "node:os";
33
import path from "node:path";
44
import sharp from "sharp";
55
import type { Plugin } from "vite";
6-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
6+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
77
import { imagePlugin } from "../vite/index";
88
import type { SolidImageOptions } from "../vite/index";
99

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

18-
function callLoad(plugin: Plugin, id: string) {
18+
function callLoad(plugin: Plugin, id: string, context: unknown = {}) {
1919
const hook = plugin.load as any;
2020
const fn = typeof hook === "function" ? hook : hook.handler;
21-
return fn.call({} as any, id, {});
21+
return fn.call(context as any, id, {});
22+
}
23+
24+
function callConfigResolved(plugin: Plugin, command: "build" | "serve") {
25+
const hook = plugin.configResolved as any;
26+
const fn = typeof hook === "function" ? hook : hook.handler;
27+
fn.call({} as any, { command } as any);
2228
}
2329

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

208214
expect(code).toContain("width: 64");
209215
expect(code).toContain("height: 32");
210-
expect(code).toContain('import source from "./photo.png"');
216+
});
217+
218+
it("points the source at the largest variant of the fallback format", async () => {
219+
const plugin = createLocalPlugin({ output: ["webp", "jpeg"], sizes: [400, 800] });
220+
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-source"));
221+
222+
// jpeg is last in the output list, so it is the format every browser reads.
223+
expect(code).toContain('import source from "./photo.png?image-raw-jpeg-800"');
224+
expect(code).not.toContain('import source from "./photo.png"');
211225
});
212226

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

266+
it("emits the file through the bundler on build", async () => {
267+
const plugin = createLocalPlugin();
268+
callConfigResolved(plugin, "build");
269+
270+
const emitFile = vi.fn((_asset: { type: string; name: string; source: Buffer }) => "abc123");
271+
const code: string = await callLoad(
272+
plugin,
273+
path.join(dir, "photo.png?image-raw-webp-400"),
274+
{ emitFile },
275+
);
276+
277+
// Going through the bundler is what makes `base`, `assetsDir` and the
278+
// manifest apply to these files.
279+
expect(code).toBe("export default import.meta.ROLLUP_FILE_URL_abc123;");
280+
expect(emitFile).toHaveBeenCalledTimes(1);
281+
282+
const emitted = emitFile.mock.calls[0]![0];
283+
expect(emitted.type).toBe("asset");
284+
expect(emitted.name).toMatch(/^i-[0-9a-f]+-400\.webp$/);
285+
286+
const meta = await sharp(emitted.source).metadata();
287+
expect(meta.format).toBe("webp");
288+
expect(meta.width).toBe(400);
289+
});
290+
291+
it("does not write to the public directory on build", async () => {
292+
const buildPublicPath = path.join(dir, "build-public");
293+
const plugin = createLocalPlugin({ publicPath: buildPublicPath });
294+
callConfigResolved(plugin, "build");
295+
296+
await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-400"), {
297+
emitFile: () => "abc123",
298+
});
299+
300+
await expect(fs.stat(buildPublicPath)).rejects.toThrow();
301+
});
302+
252303
it("uses the jpg extension for jpeg output", async () => {
253304
const plugin = createLocalPlugin();
254305
const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-jpeg-800"));

0 commit comments

Comments
 (0)