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.
@@ -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
- // In development, skip responsive images entirely to avoid 404s
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
- typeof window !== "undefined" &&
91
+ isRealBrowser &&
76
92
  (window.location.hostname === "localhost" ||
77
93
  window.location.hostname === "127.0.0.1");
78
94
 
79
- if (isDevelopment) {
80
- return (
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
- // Production mode: full responsive image functionality
95
- // Detect image dimensions to filter applicable widths
96
- const [imageDimensions, setImageDimensions] = useState(null);
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
- // Skip dimension detection when not needed
99
- const shouldDetectDimensions = src && typeof window !== "undefined";
105
+ // Detect natural image dimensions (client-only, production-only)
106
+ const [imageDimensions, setImageDimensions] = useState(null);
100
107
 
101
108
  useEffect(() => {
102
- if (!shouldDetectDimensions) return;
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, shouldDetectDimensions]);
119
+ }, [src, isRealBrowser, isDevelopment]);
113
120
 
114
- // Filter widths to only include applicable ones (image width >= target width)
115
- const applicableWidths = imageDimensions
116
- ? widths.filter((width) => imageDimensions.width >= width)
117
- : widths; // Fallback to all widths during loading
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(applicableWidths);
128
+ // Check which responsive variants actually exist at runtime (with caching)
129
+ const [availableWidths, setAvailableWidths] = useState(null);
121
130
 
122
131
  useEffect(() => {
123
- const checkAvailableVariants = async () => {
124
- if (typeof window === "undefined") {
125
- setAvailableWidths(applicableWidths);
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
- // Variant doesn't exist, skip it
159
+ variantCache.set(variantUrl, false);
142
160
  }
143
161
  }
144
162
 
145
- // Always include at least the smallest width if no variants exist
146
- if (existingWidths.length === 0 && applicableWidths.length > 0) {
147
- existingWidths.push(Math.min(...applicableWidths));
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
- // Smart priority detection if not explicitly set
157
- const autoPriority = priority ?? shouldUsePriority(src, className);
171
+ return () => {
172
+ cancelled = true;
173
+ };
174
+ }, [src, applicableWidths, isRealBrowser, isDevelopment]);
158
175
 
159
- // Lighthouse-optimized loading attributes
160
- const loading = explicitLoading || (autoPriority ? "eager" : "lazy");
161
- const fetchPriority =
162
- explicitFetchPriority || (autoPriority ? "high" : undefined);
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
- // Generate srcsets for different formats using available widths only
165
- const srcSetWebP = generateSrcSet(src, availableWidths, "webp");
166
- const srcSetOriginal = generateSrcSet(src, availableWidths);
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={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 */
@@ -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;