diff --git a/packages/browser/src/global/stabilization/plugins/loadImageSrcset.ts b/packages/browser/src/global/stabilization/plugins/loadImageSrcset.ts index 7274667c..c8f8e7cf 100644 --- a/packages/browser/src/global/stabilization/plugins/loadImageSrcset.ts +++ b/packages/browser/src/global/stabilization/plugins/loadImageSrcset.ts @@ -1,60 +1,132 @@ import type { Plugin } from ".."; /** - * Force the reload of srcset on resize. - * To ensure that if the viewport changes, it's the same behaviour - * as if the page was reloaded. + * Force srcset to resolve to the biggest candidate for the current run. + * This collapses srcset to a single candidate while staying consistent with + * descriptor rules from the spec. */ export const plugin = { name: "loadImageSrcset" as const, beforeEach(options) { - // If the user is not using viewports, do nothing. if (!options.viewports || options.viewports.length === 0) { return undefined; } - function getLargestSrcFromSrcset(srcset: string) { - // Parse srcset into array of {url, width} - const sources = srcset + type ParsedCandidate = + | { url: string; kind: "w"; value: number } + | { url: string; kind: "x"; value: number } + | { url: string; kind: "none"; value: 1 }; + + function parseSrcset(srcset: string): ParsedCandidate[] { + return srcset .split(",") - .map((item) => { - const [url, size] = item.trim().split(/\s+/); - if (!url) { - return null; + .reduce((candidates, rawPart) => { + const part = rawPart.trim(); + if (!part) { + return candidates; + } + + const tokens = part.split(/\s+/); + const maybeDescriptor = + tokens.length > 1 ? tokens[tokens.length - 1] : null; + + if (maybeDescriptor && /^\d+w$/.test(maybeDescriptor)) { + const url = tokens.slice(0, -1).join(" "); + if (url) { + candidates.push({ + url, + kind: "w", + value: Number.parseInt(maybeDescriptor.slice(0, -1), 10), + }); + } + return candidates; } - // Only handle width descriptors (e.g., 800w) - const widthMatch = size && size.match(/^(\d+)w$/); - if (!widthMatch) { - return { url, width: 0 }; + + if (maybeDescriptor && /^\d+\.?\d*x$/.test(maybeDescriptor)) { + const url = tokens.slice(0, -1).join(" "); + if (url) { + candidates.push({ + url, + kind: "x", + value: Number.parseFloat(maybeDescriptor.slice(0, -1)), + }); + } + return candidates; + } + + const url = tokens[0] ?? ""; + if (url) { + candidates.push({ url, kind: "none", value: 1 }); } - const width = parseInt(widthMatch[1]!, 10); - return { url, width }; - }) - .filter((x) => x !== null); - if (sources.length === 0) { - return srcset; + return candidates; + }, []); + } + + function pickLargestCandidate( + candidates: ParsedCandidate[], + ): ParsedCandidate | null { + let winner: ParsedCandidate | null = null; + let kind: ParsedCandidate["kind"] | null = null; + + for (const candidate of candidates) { + if (kind !== null && candidate.kind !== kind) { + return null; + } + + kind ??= candidate.kind; + + if (winner === null || candidate.value > winner.value) { + winner = candidate; + } } - // Find the source with the largest width - const largest = sources.reduce((max, curr) => - curr.width > max.width ? curr : max, - ); + return winner; + } - // Return only the largest source as srcset - return largest.url; + function candidateToSingleSrcset( + candidate: ParsedCandidate, + requireW: boolean, + ): string { + if (candidate.kind === "none") { + return requireW ? `${candidate.url} 1w` : candidate.url; + } + + return `${candidate.url} ${candidate.value}${candidate.kind}`; } - function forceSrcsetReload(img: Element) { - const srcset = img.getAttribute("srcset"); + function forceSrcsetReload(el: Element): void { + const srcset = el.getAttribute("srcset"); if (!srcset) { return; } - img.setAttribute("srcset", getLargestSrcFromSrcset(srcset)); + + const candidates = parseSrcset(srcset); + const chosen = pickLargestCandidate(candidates); + if (!chosen) { + return; + } + + const requireW = + el.hasAttribute("sizes") || + chosen.kind === "w" || + candidates.some((c) => c.kind === "w"); + + el.setAttribute("srcset", ""); + + if (el instanceof HTMLImageElement) { + el.src = chosen.url; + } + + void el.clientWidth; + + el.setAttribute("srcset", candidateToSingleSrcset(chosen, requireW)); } Array.from(document.querySelectorAll("img,source")).forEach( forceSrcsetReload, ); + + return undefined; }, } satisfies Plugin; diff --git a/packages/browser/src/global/stabilization/plugins/roundImageSize.ts b/packages/browser/src/global/stabilization/plugins/roundImageSize.ts index 5c1ceb81..8dc384ca 100644 --- a/packages/browser/src/global/stabilization/plugins/roundImageSize.ts +++ b/packages/browser/src/global/stabilization/plugins/roundImageSize.ts @@ -10,34 +10,33 @@ export const plugin = { name: "roundImageSize" as const, beforeEach() { Array.from(document.images).forEach((img) => { - // Skip images that are not loaded yet. - if (!img.complete) { + if (!img.complete || img.naturalWidth === 0) { return; } - // Backup the original width and height - img.setAttribute(BACKUP_ATTRIBUTE_WIDTH, img.style.width); - img.setAttribute(BACKUP_ATTRIBUTE_HEIGHT, img.style.height); + // Backup only once + if (!img.hasAttribute(BACKUP_ATTRIBUTE_WIDTH)) { + img.setAttribute(BACKUP_ATTRIBUTE_WIDTH, img.style.width); + img.setAttribute(BACKUP_ATTRIBUTE_HEIGHT, img.style.height); + } - // Set the width and height to the rounded values img.style.width = `${Math.round(img.offsetWidth)}px`; img.style.height = `${Math.round(img.offsetHeight)}px`; }); return () => { Array.from(document.images).forEach((img) => { - const bckWidth = img.getAttribute(BACKUP_ATTRIBUTE_WIDTH); - const bckHeight = img.getAttribute(BACKUP_ATTRIBUTE_HEIGHT); - - if (bckWidth === null && bckHeight === null) { + // Only restore if we actually backed up this image + if (!img.hasAttribute(BACKUP_ATTRIBUTE_WIDTH)) { return; } - // Restore the original width and height + const bckWidth = img.getAttribute(BACKUP_ATTRIBUTE_WIDTH); + const bckHeight = img.getAttribute(BACKUP_ATTRIBUTE_HEIGHT); + img.style.width = bckWidth ?? ""; img.style.height = bckHeight ?? ""; - // Remove the backup attributes img.removeAttribute(BACKUP_ATTRIBUTE_WIDTH); img.removeAttribute(BACKUP_ATTRIBUTE_HEIGHT); }); diff --git a/packages/browser/src/global/stabilization/plugins/waitForImages.ts b/packages/browser/src/global/stabilization/plugins/waitForImages.ts index de800f67..d553380f 100644 --- a/packages/browser/src/global/stabilization/plugins/waitForImages.ts +++ b/packages/browser/src/global/stabilization/plugins/waitForImages.ts @@ -6,13 +6,10 @@ import type { Plugin } from ".."; export const plugin = { name: "waitForImages" as const, beforeEach() { - Array.from(document.images).every((img) => { - // Force sync decoding + Array.from(document.images).forEach((img) => { if (img.decoding !== "sync") { img.decoding = "sync"; } - - // Force eager loading if (img.loading !== "eager") { img.loading = "eager"; } @@ -21,23 +18,15 @@ export const plugin = { }, wait: { for: () => { - const images = Array.from(document.images); - - const results = images.map((img) => { - // Force sync decoding + return Array.from(document.images).every((img) => { if (img.decoding !== "sync") { img.decoding = "sync"; } - - // Force eager loading if (img.loading !== "eager") { img.loading = "eager"; } - - return img.complete; + return img.complete && img.naturalWidth > 0; }); - - return results.every((x) => x); }, failureExplanation: "Some images have not been loaded", },