routerino 2.3.4 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -2
- package/dist/routerino.js +304 -246
- package/dist/routerino.umd.cjs +1 -1
- package/package.json +2 -1
- package/routerino-forge.js +176 -51
- package/routerino-image.jsx +139 -56
- package/types/routerino.d.ts +0 -1
package/routerino-image.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
1
|
+
import React, { useState, useEffect, useMemo } from "react";
|
|
2
2
|
import PropTypes from "prop-types";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -27,6 +27,9 @@ const DEFAULT_WIDTHS = [480, 800, 1200, 1920];
|
|
|
27
27
|
const DEFAULT_SIZES =
|
|
28
28
|
"(max-width: 480px) 100vw, (max-width: 800px) 800px, (max-width: 1200px) 1200px, 1920px";
|
|
29
29
|
|
|
30
|
+
// Module-level cache for HEAD request results so remounts don't re-fetch
|
|
31
|
+
const variantCache = new Map();
|
|
32
|
+
|
|
30
33
|
// Smart priority detection for common patterns
|
|
31
34
|
function shouldUsePriority(src, className = "") {
|
|
32
35
|
const srcLower = src.toLowerCase();
|
|
@@ -64,44 +67,48 @@ export function Image(props) {
|
|
|
64
67
|
sizes = DEFAULT_SIZES,
|
|
65
68
|
className = "",
|
|
66
69
|
style = {},
|
|
70
|
+
width: explicitWidth,
|
|
71
|
+
height: explicitHeight,
|
|
67
72
|
loading: explicitLoading,
|
|
68
73
|
decoding = "async",
|
|
69
74
|
fetchpriority: explicitFetchPriority,
|
|
70
75
|
...rest
|
|
71
76
|
} = props || {};
|
|
72
77
|
|
|
73
|
-
|
|
78
|
+
const isServer = typeof window === "undefined";
|
|
79
|
+
|
|
80
|
+
// Detect real browser dev environment vs SSG with a mocked window.
|
|
81
|
+
// During SSG, routerino-forge mocks `window` with hostname "localhost",
|
|
82
|
+
// but its `document.createElement` returns plain objects, not real DOM nodes.
|
|
83
|
+
// Checking `instanceof HTMLElement` distinguishes a real browser from a mock.
|
|
84
|
+
const isRealBrowser =
|
|
85
|
+
!isServer &&
|
|
86
|
+
typeof document !== "undefined" &&
|
|
87
|
+
typeof HTMLElement !== "undefined" &&
|
|
88
|
+
document.createElement("div") instanceof HTMLElement;
|
|
89
|
+
|
|
74
90
|
const isDevelopment =
|
|
75
|
-
|
|
91
|
+
isRealBrowser &&
|
|
76
92
|
(window.location.hostname === "localhost" ||
|
|
77
93
|
window.location.hostname === "127.0.0.1");
|
|
78
94
|
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
<img
|
|
82
|
-
src={src}
|
|
83
|
-
alt={alt}
|
|
84
|
-
loading={explicitLoading || "lazy"}
|
|
85
|
-
decoding={decoding}
|
|
86
|
-
fetchPriority={explicitFetchPriority}
|
|
87
|
-
className={className}
|
|
88
|
-
style={style}
|
|
89
|
-
{...rest}
|
|
90
|
-
/>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
95
|
+
// Smart priority detection if not explicitly set (used by all render paths)
|
|
96
|
+
const autoPriority = priority ?? shouldUsePriority(src, className);
|
|
93
97
|
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
const
|
|
98
|
+
// Lighthouse-optimized loading attributes
|
|
99
|
+
const loading = explicitLoading || (autoPriority ? "eager" : "lazy");
|
|
100
|
+
const fetchPriority =
|
|
101
|
+
explicitFetchPriority || (autoPriority ? "high" : undefined);
|
|
102
|
+
|
|
103
|
+
// --- All hooks are called unconditionally, before any early returns ---
|
|
97
104
|
|
|
98
|
-
//
|
|
99
|
-
const
|
|
105
|
+
// Detect natural image dimensions (client-only, production-only)
|
|
106
|
+
const [imageDimensions, setImageDimensions] = useState(null);
|
|
100
107
|
|
|
101
108
|
useEffect(() => {
|
|
102
|
-
if (!
|
|
109
|
+
if (!isRealBrowser || isDevelopment || !src) return;
|
|
103
110
|
|
|
104
|
-
const img = new Image();
|
|
111
|
+
const img = new window.Image();
|
|
105
112
|
img.onload = () => {
|
|
106
113
|
setImageDimensions({
|
|
107
114
|
width: img.naturalWidth,
|
|
@@ -109,64 +116,135 @@ export function Image(props) {
|
|
|
109
116
|
});
|
|
110
117
|
};
|
|
111
118
|
img.src = src;
|
|
112
|
-
}, [src,
|
|
119
|
+
}, [src, isRealBrowser, isDevelopment]);
|
|
113
120
|
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
// Memoize applicableWidths so the reference is stable and doesn't
|
|
122
|
+
// cause the downstream useEffect to re-run on every render
|
|
123
|
+
const applicableWidths = useMemo(() => {
|
|
124
|
+
if (!imageDimensions) return widths;
|
|
125
|
+
return widths.filter((w) => imageDimensions.width >= w);
|
|
126
|
+
}, [imageDimensions, widths]);
|
|
118
127
|
|
|
119
|
-
// Check which responsive variants actually exist at runtime
|
|
120
|
-
const [availableWidths, setAvailableWidths] = useState(
|
|
128
|
+
// Check which responsive variants actually exist at runtime (with caching)
|
|
129
|
+
const [availableWidths, setAvailableWidths] = useState(null);
|
|
121
130
|
|
|
122
131
|
useEffect(() => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
132
|
+
if (!isRealBrowser || isDevelopment) return;
|
|
133
|
+
|
|
134
|
+
let cancelled = false;
|
|
128
135
|
|
|
136
|
+
const checkAvailableVariants = async () => {
|
|
129
137
|
const base = src.replace(/\.(jpe?g|png|webp)$/i, "");
|
|
130
138
|
const ext = src.match(/\.(jpe?g|png|webp)$/i)?.[0] || ".jpg";
|
|
131
139
|
|
|
132
140
|
const existingWidths = [];
|
|
133
141
|
for (const width of applicableWidths) {
|
|
134
142
|
const variantUrl = `${base}-${width}w${ext}`;
|
|
143
|
+
|
|
144
|
+
// Check module-level cache first
|
|
145
|
+
if (variantCache.has(variantUrl)) {
|
|
146
|
+
if (variantCache.get(variantUrl)) {
|
|
147
|
+
existingWidths.push(width);
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
135
152
|
try {
|
|
136
153
|
const response = await fetch(variantUrl, { method: "HEAD" });
|
|
154
|
+
variantCache.set(variantUrl, response.ok);
|
|
137
155
|
if (response.ok) {
|
|
138
156
|
existingWidths.push(width);
|
|
139
157
|
}
|
|
140
158
|
} catch {
|
|
141
|
-
|
|
159
|
+
variantCache.set(variantUrl, false);
|
|
142
160
|
}
|
|
143
161
|
}
|
|
144
162
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
existingWidths.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
setAvailableWidths(existingWidths);
|
|
163
|
+
if (cancelled) return;
|
|
164
|
+
setAvailableWidths(
|
|
165
|
+
existingWidths.length > 0 ? existingWidths : applicableWidths
|
|
166
|
+
);
|
|
151
167
|
};
|
|
152
168
|
|
|
153
169
|
checkAvailableVariants();
|
|
154
|
-
}, [src, applicableWidths]);
|
|
155
170
|
|
|
156
|
-
|
|
157
|
-
|
|
171
|
+
return () => {
|
|
172
|
+
cancelled = true;
|
|
173
|
+
};
|
|
174
|
+
}, [src, applicableWidths, isRealBrowser, isDevelopment]);
|
|
158
175
|
|
|
159
|
-
//
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
176
|
+
// Resolve final widths: use availableWidths once known, else applicableWidths
|
|
177
|
+
const finalWidths = availableWidths ?? applicableWidths;
|
|
178
|
+
|
|
179
|
+
// Dimension attributes for CLS prevention
|
|
180
|
+
const dimensionProps = {};
|
|
181
|
+
if (explicitWidth != null) dimensionProps.width = explicitWidth;
|
|
182
|
+
if (explicitHeight != null) dimensionProps.height = explicitHeight;
|
|
183
|
+
if (imageDimensions && explicitWidth == null && explicitHeight == null) {
|
|
184
|
+
dimensionProps.width = imageDimensions.width;
|
|
185
|
+
dimensionProps.height = imageDimensions.height;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Protective default styles: ensure width/height HTML attributes act as CLS
|
|
189
|
+
// hints only, never overriding CSS-driven layouts (Tailwind classes, etc.).
|
|
190
|
+
// height:auto lets constrained-width images scale proportionally.
|
|
191
|
+
// max-width:100% prevents images from exceeding their container.
|
|
192
|
+
// User's style prop is spread last so it can override any default.
|
|
193
|
+
const imgStyle = {
|
|
194
|
+
maxWidth: "100%",
|
|
195
|
+
height: "auto",
|
|
196
|
+
...style,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// --- Render paths (after all hooks) ---
|
|
200
|
+
|
|
201
|
+
// In development, skip responsive images entirely to avoid 404s
|
|
202
|
+
if (isDevelopment) {
|
|
203
|
+
return (
|
|
204
|
+
<img
|
|
205
|
+
src={src}
|
|
206
|
+
alt={alt}
|
|
207
|
+
loading={loading}
|
|
208
|
+
decoding={decoding}
|
|
209
|
+
fetchPriority={fetchPriority}
|
|
210
|
+
className={className}
|
|
211
|
+
style={imgStyle}
|
|
212
|
+
{...dimensionProps}
|
|
213
|
+
{...rest}
|
|
214
|
+
/>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Server-side render path
|
|
219
|
+
if (isServer) {
|
|
220
|
+
return (
|
|
221
|
+
<picture data-routerino-image="true" data-original-src={src}>
|
|
222
|
+
<source
|
|
223
|
+
srcSet={generateSrcSet(src, widths, "webp")}
|
|
224
|
+
type="image/webp"
|
|
225
|
+
sizes={sizes}
|
|
226
|
+
/>
|
|
227
|
+
<img
|
|
228
|
+
src={src}
|
|
229
|
+
alt={alt}
|
|
230
|
+
srcSet={generateSrcSet(src, widths)}
|
|
231
|
+
sizes={sizes}
|
|
232
|
+
loading={loading}
|
|
233
|
+
decoding="async"
|
|
234
|
+
fetchPriority={fetchPriority}
|
|
235
|
+
className={className}
|
|
236
|
+
style={imgStyle}
|
|
237
|
+
{...dimensionProps}
|
|
238
|
+
{...rest}
|
|
239
|
+
/>
|
|
240
|
+
</picture>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
163
243
|
|
|
164
|
-
//
|
|
165
|
-
const srcSetWebP = generateSrcSet(src,
|
|
166
|
-
const srcSetOriginal = generateSrcSet(src,
|
|
244
|
+
// Production client render
|
|
245
|
+
const srcSetWebP = generateSrcSet(src, finalWidths, "webp");
|
|
246
|
+
const srcSetOriginal = generateSrcSet(src, finalWidths);
|
|
167
247
|
|
|
168
|
-
// For SSG: This will be processed by routerino-forge.js to include LQIP
|
|
169
|
-
// The data-routerino-image attribute signals to the forge to process this
|
|
170
248
|
return (
|
|
171
249
|
<picture data-routerino-image="true" data-original-src={src}>
|
|
172
250
|
<source srcSet={srcSetWebP} type="image/webp" sizes={sizes} />
|
|
@@ -179,7 +257,8 @@ export function Image(props) {
|
|
|
179
257
|
decoding={decoding}
|
|
180
258
|
fetchPriority={fetchPriority}
|
|
181
259
|
className={className}
|
|
182
|
-
style={
|
|
260
|
+
style={imgStyle}
|
|
261
|
+
{...dimensionProps}
|
|
183
262
|
{...rest}
|
|
184
263
|
/>
|
|
185
264
|
</picture>
|
|
@@ -201,6 +280,10 @@ Image.propTypes = {
|
|
|
201
280
|
className: PropTypes.string,
|
|
202
281
|
/** Inline styles */
|
|
203
282
|
style: PropTypes.object,
|
|
283
|
+
/** Explicit width for CLS prevention */
|
|
284
|
+
width: PropTypes.number,
|
|
285
|
+
/** Explicit height for CLS prevention */
|
|
286
|
+
height: PropTypes.number,
|
|
204
287
|
/** Loading behavior (auto-set based on priority) */
|
|
205
288
|
loading: PropTypes.oneOf(["lazy", "eager"]),
|
|
206
289
|
/** Decode timing */
|
package/types/routerino.d.ts
CHANGED
|
@@ -109,7 +109,6 @@ export function Routerino(props: RouterinoProps): JSX.Element;
|
|
|
109
109
|
|
|
110
110
|
// Image component exports
|
|
111
111
|
export function Image(props: ImageProps): JSX.Element;
|
|
112
|
-
export { Image as default as ImageComponent };
|
|
113
112
|
|
|
114
113
|
// Default export for backward compatibility
|
|
115
114
|
export default Routerino;
|