Skip to content

Commit 16bf4da

Browse files
committed
Move image as standalone component
1 parent c5800f0 commit 16bf4da

21 files changed

Lines changed: 1923 additions & 312 deletions

apps/tests/src/routes/image-local.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { StartImage as Image } from "@solidjs/start/image";
1+
import { SolidImage as Image } from "@solidjs/start/image";
22
import { type JSX, onMount, Show } from "solid-js";
33
import exampleImage from "../images/example.jpg?image";
44

apps/tests/src/routes/image-remote.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { StartImage as Image } from "@solidjs/start/image";
1+
import { SolidImage as Image } from "@solidjs/start/image";
22
import { type JSX, onMount, Show } from "solid-js";
33
// local
44
// import exampleImage from './example.jpg?image';

packages/image/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@solidjs/image",
3+
"scripts": {
4+
"prepublishOnly": "tsdown",
5+
"build": "tsdown",
6+
"watch": "tsdown --watch"
7+
},
8+
"devDependencies": {
9+
"@tsdown/css": "^0.22.12",
10+
"solid-js": "^1.9.9",
11+
"tsdown": "^0.22.12",
12+
"vite": "^8.1.5",
13+
"vitest": "^4.0.10"
14+
},
15+
"dependencies": {
16+
"sharp": "^0.35.3"
17+
},
18+
"peerDependencies": {
19+
"solid-js": "^1.9.9",
20+
"vite": "^8.1.5"
21+
},
22+
"exports": {
23+
".": "./dist/index.jsx",
24+
"./env": "./dist/env.js",
25+
"./vite": "./dist/vite.js",
26+
"./package.json": "./package.json",
27+
"./style.css": "./dist/style.css"
28+
}
29+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { createRoot } from "solid-js";
2+
import { renderToString } from "solid-js/web";
3+
import { describe, expect, it } from "vitest";
4+
import { ClientOnly, createClientSignal } from "../core/client-only";
5+
import { createLazyRender } from "../core/create-lazy-render";
6+
import { SolidImage } from "../core/index";
7+
8+
// ---------------------------------------------------------------------------
9+
// createLazyRender
10+
// ---------------------------------------------------------------------------
11+
describe("createLazyRender", () => {
12+
it("starts with visible = false", () => {
13+
let visible: boolean | undefined;
14+
15+
createRoot(dispose => {
16+
const laze = createLazyRender<HTMLDivElement>();
17+
visible = laze.visible;
18+
dispose();
19+
});
20+
21+
expect(visible).toBe(false);
22+
});
23+
24+
it("exposes a callable ref setter", () => {
25+
createRoot(dispose => {
26+
const laze = createLazyRender<HTMLDivElement>();
27+
expect(typeof laze.ref).toBe("function");
28+
dispose();
29+
});
30+
});
31+
32+
it("returns correct shape with refresh option", () => {
33+
createRoot(dispose => {
34+
const laze = createLazyRender<HTMLDivElement>({ refresh: true });
35+
expect(typeof laze.ref).toBe("function");
36+
expect(laze.visible).toBe(false);
37+
dispose();
38+
});
39+
});
40+
});
41+
42+
// ---------------------------------------------------------------------------
43+
// createClientSignal (server context -- isServer is true in Node)
44+
// ---------------------------------------------------------------------------
45+
describe("createClientSignal", () => {
46+
it("returns a function that resolves to false on the server", () => {
47+
const signal = createClientSignal();
48+
expect(typeof signal).toBe("function");
49+
expect(signal()).toBe(false);
50+
});
51+
});
52+
53+
// ---------------------------------------------------------------------------
54+
// ClientOnly (server context)
55+
// ---------------------------------------------------------------------------
56+
describe("ClientOnly", () => {
57+
it("renders the fallback in a server environment", () => {
58+
const html = renderToString(() => (
59+
<ClientOnly fallback={<span data-test="fallback">loading</span>}>
60+
<span data-test="child">client content</span>
61+
</ClientOnly>
62+
));
63+
64+
expect(html).toContain("loading");
65+
expect(html).not.toContain("client content");
66+
});
67+
68+
it("renders the fallback element when no children are given", () => {
69+
const html = renderToString(() => <ClientOnly fallback={<div>placeholder</div>} />);
70+
71+
expect(html).toContain("placeholder");
72+
});
73+
});
74+
75+
// ---------------------------------------------------------------------------
76+
// SolidImage SSR regression
77+
// ---------------------------------------------------------------------------
78+
describe("SolidImage SSR", () => {
79+
it("does not throw ReferenceError: document is not defined", () => {
80+
expect(() => {
81+
renderToString(() => (
82+
<SolidImage
83+
src={{ source: "/test.jpg", width: 100, height: 100, options: {} }}
84+
alt="test"
85+
fallback={() => <div>loading</div>}
86+
/>
87+
));
88+
}).not.toThrow();
89+
});
90+
91+
it("produces HTML containing the image container", () => {
92+
const html = renderToString(() => (
93+
<SolidImage
94+
src={{ source: "/test.jpg", width: 100, height: 100, options: {} }}
95+
alt="test"
96+
fallback={() => <div>loading</div>}
97+
/>
98+
));
99+
100+
expect(html).toContain("data-start-image");
101+
expect(html).toContain("test.jpg");
102+
});
103+
104+
it("renders without a transformer (default <source> fallback path)", () => {
105+
const html = renderToString(() => (
106+
<SolidImage
107+
src={{ source: "/hero.png", width: 800, height: 600, options: {} }}
108+
alt="hero image"
109+
fallback={() => <span>placeholder</span>}
110+
/>
111+
));
112+
113+
expect(html).toContain("hero.png");
114+
expect(html).toContain('alt="hero image"');
115+
});
116+
});
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
createImageVariants,
4+
mergeImageVariantsByType,
5+
mergeImageVariantsToSrcSet,
6+
} from "../core/transformer";
7+
import type { SolidImageSource, SolidImageTransformer, SolidImageVariant } from "../core/types";
8+
9+
describe("createImageVariants", () => {
10+
it("returns an array with 1 item when transformer returns a single variant", () => {
11+
const source: SolidImageSource<{}> = {
12+
source: "/img/photo.jpg",
13+
width: 800,
14+
height: 600,
15+
options: {},
16+
};
17+
18+
const transformer: SolidImageTransformer<{}> = {
19+
transform: () => ({
20+
path: "/img/photo-800.webp",
21+
width: 800,
22+
type: "image/webp",
23+
}),
24+
};
25+
26+
const result = createImageVariants(source, transformer);
27+
28+
expect(result).toHaveLength(1);
29+
expect(result[0]).toEqual({
30+
path: "/img/photo-800.webp",
31+
width: 800,
32+
type: "image/webp",
33+
});
34+
});
35+
36+
it("returns an array with 3 items when transformer returns 3 variants", () => {
37+
const source: SolidImageSource<{}> = {
38+
source: "/img/hero.png",
39+
width: 1200,
40+
height: 800,
41+
options: {},
42+
};
43+
44+
const variants: SolidImageVariant[] = [
45+
{ path: "/img/hero-400.avif", width: 400, type: "image/avif" },
46+
{ path: "/img/hero-800.avif", width: 800, type: "image/avif" },
47+
{ path: "/img/hero-1200.avif", width: 1200, type: "image/avif" },
48+
];
49+
50+
const transformer: SolidImageTransformer<{}> = {
51+
transform: () => variants,
52+
};
53+
54+
const result = createImageVariants(source, transformer);
55+
56+
expect(result).toHaveLength(3);
57+
expect(result[0]).toEqual({
58+
path: "/img/hero-400.avif",
59+
width: 400,
60+
type: "image/avif",
61+
});
62+
expect(result[1]).toEqual({
63+
path: "/img/hero-800.avif",
64+
width: 800,
65+
type: "image/avif",
66+
});
67+
expect(result[2]).toEqual({
68+
path: "/img/hero-1200.avif",
69+
width: 1200,
70+
type: "image/avif",
71+
});
72+
});
73+
74+
it("passes the source to the transformer", () => {
75+
const source: SolidImageSource<{ quality: number }> = {
76+
source: "/img/test.jpg",
77+
width: 500,
78+
height: 300,
79+
options: { quality: 80 },
80+
};
81+
82+
let receivedSource: SolidImageSource<{ quality: number }> | undefined;
83+
84+
const transformer: SolidImageTransformer<{ quality: number }> = {
85+
transform: src => {
86+
receivedSource = src;
87+
return { path: "/img/test-500.webp", width: 500, type: "image/webp" };
88+
},
89+
};
90+
91+
createImageVariants(source, transformer);
92+
93+
expect(receivedSource).toBe(source);
94+
});
95+
});
96+
97+
describe("mergeImageVariantsByType", () => {
98+
it("groups variants by their MIME type", () => {
99+
const variants: SolidImageVariant[] = [
100+
{ path: "/img/a-400.webp", width: 400, type: "image/webp" },
101+
{ path: "/img/a-800.webp", width: 800, type: "image/webp" },
102+
{ path: "/img/a-400.avif", width: 400, type: "image/avif" },
103+
{ path: "/img/a-800.avif", width: 800, type: "image/avif" },
104+
];
105+
106+
const result = mergeImageVariantsByType(variants);
107+
108+
expect(result.size).toBe(2);
109+
110+
const webpGroup = result.get("image/webp")!;
111+
expect(webpGroup).toHaveLength(2);
112+
expect(webpGroup[0]).toEqual({
113+
path: "/img/a-400.webp",
114+
width: 400,
115+
type: "image/webp",
116+
});
117+
expect(webpGroup[1]).toEqual({
118+
path: "/img/a-800.webp",
119+
width: 800,
120+
type: "image/webp",
121+
});
122+
123+
const avifGroup = result.get("image/avif")!;
124+
expect(avifGroup).toHaveLength(2);
125+
expect(avifGroup[0]).toEqual({
126+
path: "/img/a-400.avif",
127+
width: 400,
128+
type: "image/avif",
129+
});
130+
expect(avifGroup[1]).toEqual({
131+
path: "/img/a-800.avif",
132+
width: 800,
133+
type: "image/avif",
134+
});
135+
});
136+
137+
it("returns a Map with 1 entry when all variants share the same type", () => {
138+
const variants: SolidImageVariant[] = [
139+
{ path: "/img/x-100.png", width: 100, type: "image/png" },
140+
{ path: "/img/x-200.png", width: 200, type: "image/png" },
141+
];
142+
143+
const result = mergeImageVariantsByType(variants);
144+
145+
expect(result.size).toBe(1);
146+
expect(result.get("image/png")).toHaveLength(2);
147+
});
148+
});
149+
150+
describe("mergeImageVariantsToSrcSet", () => {
151+
it("formats a single variant into a srcset string", () => {
152+
const variants: SolidImageVariant[] = [
153+
{ path: "/img/photo-100.webp", width: 100, type: "image/webp" },
154+
];
155+
156+
const result = mergeImageVariantsToSrcSet(variants);
157+
158+
expect(result).toBe("/img/photo-100.webp 100w");
159+
});
160+
161+
it("formats multiple variants into a comma-separated srcset string", () => {
162+
const variants: SolidImageVariant[] = [
163+
{ path: "/img/photo-100.webp", width: 100, type: "image/webp" },
164+
{ path: "/img/photo-200.webp", width: 200, type: "image/webp" },
165+
{ path: "/img/photo-400.webp", width: 400, type: "image/webp" },
166+
];
167+
168+
const result = mergeImageVariantsToSrcSet(variants);
169+
170+
expect(result).toBe(
171+
"/img/photo-100.webp 100w,/img/photo-200.webp 200w,/img/photo-400.webp 400w",
172+
);
173+
});
174+
});

packages/image/src/core/index.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ import {
77
mergeImageVariantsByType,
88
mergeImageVariantsToSrcSet,
99
} from "./transformer.ts";
10-
import type { StartImageSource, StartImageTransformer, StartImageVariant } from "./types.ts";
10+
import type { SolidImageSource, SolidImageTransformer, SolidImageVariant } from "./types.ts";
1111
import { getAspectRatioBoxStyle } from "./utils.ts";
1212

1313
import "./styles.css";
1414

15-
export interface StartImageProps<T> {
16-
src: StartImageSource<T>;
15+
export interface SolidImageProps<T> {
16+
src: SolidImageSource<T>;
1717
alt: string;
18-
transformer?: StartImageTransformer<T>;
18+
transformer?: SolidImageTransformer<T>;
1919

2020
onLoad?: () => void;
2121
fallback: (visible: () => boolean, onLoad: () => void) => JSX.Element;
@@ -25,11 +25,11 @@ export interface StartImageProps<T> {
2525
decoding?: "sync" | "async" | "auto" | undefined;
2626
}
2727

28-
interface StartImageSourcesProps<T> extends StartImageProps<T> {
29-
variants: StartImageVariant[];
28+
interface SolidImageSourcesProps<T> extends SolidImageProps<T> {
29+
variants: SolidImageVariant[];
3030
}
3131

32-
function StartImageSources<T>(props: StartImageSourcesProps<T>): JSX.Element {
32+
function SolidImageSources<T>(props: SolidImageSourcesProps<T>): JSX.Element {
3333
const mergedVariants = createMemo(() => {
3434
const types = mergeImageVariantsByType(props.variants);
3535

@@ -47,7 +47,7 @@ function StartImageSources<T>(props: StartImageSourcesProps<T>): JSX.Element {
4747
);
4848
}
4949

50-
export function StartImage<T>(props: StartImageProps<T>): JSX.Element {
50+
export function SolidImage<T>(props: SolidImageProps<T>): JSX.Element {
5151
const [showPlaceholder, setShowPlaceholder] = createSignal(true);
5252
const laze = createLazyRender<HTMLDivElement>();
5353
const [defer, setDefer] = createSignal(true);
@@ -70,7 +70,7 @@ export function StartImage<T>(props: StartImageProps<T>): JSX.Element {
7070
>
7171
<picture data-start-image="picture">
7272
<Show when={props.transformer} fallback={<source src={props.src.source} />}>
73-
{cb => <StartImageSources variants={createImageVariants(props.src, cb())} {...props} />}
73+
{cb => <SolidImageSources variants={createImageVariants(props.src, cb())} {...props} />}
7474
</Show>
7575
<ClientOnly
7676
fallback={
@@ -116,3 +116,5 @@ export function StartImage<T>(props: StartImageProps<T>): JSX.Element {
116116
</div>
117117
);
118118
}
119+
120+
export * from "./types";

0 commit comments

Comments
 (0)