diff --git a/.changeset/preload-identity-qualifiers.md b/.changeset/preload-identity-qualifiers.md new file mode 100644 index 000000000..44be51050 --- /dev/null +++ b/.changeset/preload-identity-qualifiers.md @@ -0,0 +1,13 @@ +--- +"@solidjs/web": patch +--- + +Canonicalize resource identity qualifiers instead of comparing raw prop values, so two declarations of one request dedupe to one `` on both sides of hydration. + +`false` now means absent, matching both attribute writers: `crossorigin={cond && "anonymous"}` no longer emits a second, byte-identical link when the condition is false. + +`crossorigin` is compared by its CORS state rather than its spelling. It is a CORS settings attribute with three states — absent is No CORS, `use-credentials` (ASCII case-insensitive) is Use Credentials, and every other present value including `""`, a bare attribute and an invalid one is Anonymous — so the same font is no longer preloaded once per spelling, and the client adopts the server's link instead of mounting a second one for a request the browser already has. + +Qualifier values are length-prefixed, so a value containing the identity delimiters can no longer collide with a different qualifier set and silently suppress another resource (`type: "a:media=b"` and `type: "a", media: "b"` were one identity). + +Client-side adoption of a mount-once head resource now matches a server-emitted element on the full request identity rather than the href alone: two preloads sharing an href still differ if their destination, CORS mode, type, media or source set differ. The document client, the standalone frame client and the server all apply the same rules. diff --git a/.changeset/responsive-preload-links.md b/.changeset/responsive-preload-links.md new file mode 100644 index 000000000..b853a2f6a --- /dev/null +++ b/.changeset/responsive-preload-links.md @@ -0,0 +1,11 @@ +--- +"@solidjs/web": patch +--- + +Support `imagesrcset` and `imagesizes` in typed image preloads, including the standard form without `href`. Candidate URLs inside `imagesrcset` must already be resolved by the integration. + +The responsive pair is image-only. On any other destination the attribute is dropped and the link still ships — an integration that computes `imagesrcset` for every asset keeps its script and style preloads. An empty or non-string value counts as absent for the same reason, so a source set is never emitted as garbage the browser cannot parse. A descriptor whose only source was such a filtered attribute is dropped entirely rather than emitted as a `` with nothing to fetch. + +`mountHeadResource` can adopt a source-set link: it has no href, so it matches a server-emitted link on a null href plus the identity qualifiers — the rule the frame client already applied. + +Development builds warn when `imagesrcset` uses a width descriptor without `imagesizes` (the source size falls back to `100vw`, so the preload can miss the image the `` selects), and when a manifest source set carries a relative candidate — candidates are not joined with `_base`, so they resolve against the document URL whichever base the manifest declares. That check walks the source set the way the spec's parser does, so commas inside a candidate URL are not mistaken for candidate separators. diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index 2d1cf7292..7d3662af3 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -48,7 +48,7 @@ export type FrameChunk = modules?: string[]; styles?: string[]; inlineStyles?: { id: string; content?: string; attrs?: Record }[]; - preloads?: { href: string; attrs: Record }[]; + preloads?: { href?: string; attrs: Record }[]; } | { type: "slot"; id: string; version: number; key: string; args: Record } | { type: "complete"; id: string; version: number } @@ -2061,6 +2061,21 @@ function parseFragment(html) { // Mirrors head.ts without importing it into the standalone frame client. const PRELOAD_QUALIFIERS = ["as", "crossorigin", "type", "media", "imagesrcset", "imagesizes"]; +// Mirrors head.ts's qualifierValue — keep them in step. `as` folds ASCII +// case; an empty source set or size reads as absent (registration never +// emits one); `crossorigin` is three states, not a string range, so `""`, a +// bare attribute and `anonymous` are one request. Frame `attrs` are already +// canonical strings, but the document may carry any spelling. +function qualifierValue(name, value) { + if (value == null) return null; + if (name === "imagesrcset" || name === "imagesizes") return value === "" ? null : value; + if (name === "as") return value.replace(/[A-Z]/g, c => String.fromCharCode(c.charCodeAt(0) + 32)); + if (name !== "crossorigin") return value; + return value.length === 15 && value.toLowerCase() === "use-credentials" + ? "use-credentials" + : "anonymous"; +} + /** Attribute-compared head lookup so href/id values never need escaping. */ function findHeadElement(selector, attr, value, qualifiers) { candidate: for (const node of document.head.querySelectorAll(selector)) { @@ -2068,7 +2083,11 @@ function findHeadElement(selector, attr, value, qualifiers) { if (!qualifiers) return node; for (let i = 0; i < PRELOAD_QUALIFIERS.length; i++) { const name = PRELOAD_QUALIFIERS[i]; - if (node.getAttribute(name) !== (qualifiers[name] ?? null)) continue candidate; + if ( + qualifierValue(name, node.getAttribute(name)) !== + qualifierValue(name, qualifiers[name] ?? null) + ) + continue candidate; } return node; } @@ -2078,11 +2097,12 @@ function findHeadElement(selector, attr, value, qualifiers) { /** Ensure one typed preload exists, preserving request-qualifying attributes. */ function ensurePreload(entry) { const attrs = entry.attrs; - if (findHeadElement('link[rel="preload"]', "href", entry.href, attrs)) return; + const href = entry.href; + if (findHeadElement('link[rel="preload"]', "href", href || null, attrs)) return; const link = document.createElement("link"); link.rel = "preload"; for (const name in attrs) link.setAttribute(name, attrs[name]); - link.setAttribute("href", entry.href); + if (href) link.setAttribute("href", href); document.head.appendChild(link); } diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 0c888ed22..b2a7a8b5c 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -171,7 +171,7 @@ export { } from "./frame-transport.js"; function wirePreload(entry) { - return { href: entry.href, attrs: entry.attrs }; + return entry.href ? { href: entry.href, attrs: entry.attrs } : { attrs: entry.attrs }; } /** diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index a1b2b1909..cf5b251ad 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -134,6 +134,8 @@ import { resourceIdentity, replaceableIdentity, resolveHead, + RESOURCE_QUALIFIERS, + qualifierValue, STYLESHEET_FETCH_META } from "./head.js"; export { @@ -1019,10 +1021,25 @@ function assetEntryKey(descriptor) { // Attribute-compared lookup (instead of an attribute selector) so href/id // values never need selector escaping. -function findAssetElement(selector, attr, value) { +// `qualifiers` narrows a match to the same request: two preloads sharing an +// href still differ if their destination, CORS mode or source set differ, so +// adopting across them would drop a link the server meant to emit. Both sides +// go through `qualifierValue`, the same canonicalization the identity uses — +// a server-emitted `crossorigin=""` and an authored `crossorigin="anonymous"` +// are one request, so adoption must see them as one. +function findAssetElement(selector, attr, value, qualifiers) { const nodes = document.querySelectorAll(selector); - for (let i = 0; i < nodes.length; i++) { - if (nodes[i].getAttribute(attr) === value) return nodes[i]; + outer: for (let i = 0; i < nodes.length; i++) { + if (nodes[i].getAttribute(attr) !== value) continue; + if (!qualifiers) return nodes[i]; + for (let q = 0; q < RESOURCE_QUALIFIERS.length; q++) { + const name = RESOURCE_QUALIFIERS[q]; + if ( + qualifierValue(name, qualifiers[name]) !== qualifierValue(name, nodes[i].getAttribute(name)) + ) + continue outer; + } + return nodes[i]; } return null; } @@ -1431,10 +1448,15 @@ function mountHeadResource(tag, props) { headMountedResources.add(identity); const url = props.href || props.src; let el = null; - if (url != null) { - // Adopt a server-emitted element for the same resource. `rel` values are - // constrained to the resource set, so embedding in a selector is safe. - if (tag === "link") el = findAssetElement(`link[rel="${props.rel}"]`, "href", url); + // Adopt a server-emitted element for the same resource. `rel` values are + // constrained to the resource set, so embedding in a selector is safe. + // A responsive image preload legitimately has no href — the source set is + // the request — so it matches on a null href plus the identity qualifiers, + // the same rule the frame client applies. + if (tag === "link" && url == null && typeof props.imagesrcset === "string") + el = findAssetElement(`link[rel="${props.rel}"]`, "href", null, props); + else if (url != null) { + if (tag === "link") el = findAssetElement(`link[rel="${props.rel}"]`, "href", url, props); else if (tag === "script") el = findAssetElement("script[src]", "src", url); else el = findAssetElement("style[href]", "href", url); } diff --git a/packages/web/src/head.ts b/packages/web/src/head.ts index d16d52d3c..b1255f754 100644 --- a/packages/web/src/head.ts +++ b/packages/web/src/head.ts @@ -33,7 +33,14 @@ export const RESOURCE_LINK_RELS = new Set([ // changes cacheability. URL alone is not the identity. Integrity, referrer // policy, and fetch priority are deliberately first-registration metadata: // conflicting declarations do not create another resource identity. -const RESOURCE_QUALIFIERS = ["as", "crossorigin", "type", "media", "imagesrcset", "imagesizes"]; +export const RESOURCE_QUALIFIERS = [ + "as", + "crossorigin", + "type", + "media", + "imagesrcset", + "imagesizes" +]; // Stylesheet attributes that are pure fetch metadata: they change how the // sheet is fetched, not whether it applies. A stylesheet whose extra @@ -80,13 +87,58 @@ export function classifyHeadTag(desc) { return { resource: false }; } -// Identity for a resource-class tag (evaluated props). +// Canonical comparison value for one qualifier, or `null` when the attribute +// is not part of the request. Two rules the raw prop value does not express: +// +// - `false` is ABSENCE, not a value. Both attribute writers drop it, so +// `crossorigin={cond && "anonymous"}` must not fork an identity whose +// markup is byte-identical to the unqualified one. +// - `crossorigin` is a CORS settings attribute: three states, not a string +// range. Absent is No CORS; `use-credentials` (ASCII case-insensitive) is +// Use Credentials; every OTHER present value — `""`, a bare attribute, an +// invalid value — is Anonymous. `crossorigin=""` and `crossorigin="anonymous"` +// are one request and must be one identity, or the same font ships twice +// and the client mounts a second link instead of adopting the server's. +// - `as` is an enumerated attribute, ASCII case-insensitive: registration +// lowercases it before emitting, so identity and adoption must fold the +// same way or a client `as="IMAGE"` never adopts the server's `as="image"`. +// - the responsive pair is filtered at registration: `""` and any +// non-string value never reach the markup, so they must read as absent +// here too, or a client `imagesrcset: ""` mounts a second link beside a +// server link that (correctly) carries none. +// The standalone frame client mirrors this function; keep them in step. +export function qualifierValue(name, value) { + if (value == null || value === false) return null; + if (name === "imagesrcset" || name === "imagesizes") + return typeof value === "string" && value !== "" ? value : null; + const v = value === true ? "" : String(value); + if (name === "as") return asciiLowerCase(v); + if (name !== "crossorigin") return v; + return v.length === 15 && v.toLowerCase() === "use-credentials" ? v.toLowerCase() : "anonymous"; +} + +// HTML compares rel/as ASCII case-insensitively; toLowerCase would fold a +// non-ASCII character onto an ASCII one the parser never matches. +export function asciiLowerCase(value) { + return value.replace(/[A-Z]/g, c => String.fromCharCode(c.charCodeAt(0) + 32)); +} + +// Identity for a resource-class tag (evaluated props). Qualifier values are +// length-prefixed: plain `:q=value` concatenation let a value containing the +// delimiters forge another qualifier (`type: "a:media=b"` collided with +// `type: "a", media: "b"`), which silently dropped the second resource. The +// responsive attributes made that reachable — a source set is a long free-form +// string that routinely carries `:` and `=`. The URL is length-prefixed for the +// same reason: it is free-form too, and `/loader:type=6:module` with no `type` +// otherwise reads as `/loader` with `type: "module"`. `tag` and `rel` come +// from closed sets and cannot carry the delimiters. export function resourceIdentity(tag, props) { - let id = "res:" + tag + ":" + (props.rel || "") + ":" + (props.href || props.src || ""); + const url = String(props.href || props.src || ""); + let id = "res:" + tag + ":" + (props.rel || "") + ":" + url.length + ":" + url; for (let i = 0; i < RESOURCE_QUALIFIERS.length; i++) { const q = RESOURCE_QUALIFIERS[i]; - const value = props[q]; - if (value != null) id += ":" + q + "=" + (value === true ? "" : value); + const value = qualifierValue(q, props[q]); + if (value !== null) id += ":" + q + "=" + value.length + ":" + value; } return id; } diff --git a/packages/web/src/server-mock.ts b/packages/web/src/server-mock.ts index 6559d9143..aa3f67a57 100644 --- a/packages/web/src/server-mock.ts +++ b/packages/web/src/server-mock.ts @@ -8,10 +8,7 @@ function throwInBrowser(func: Function) { console.error(err); } -/** An explicit `` emitted by the SSR asset pipeline. */ -export type PreloadLink = { - href: string; - as: JSX.HTMLPreloadAs; +type PreloadLinkAttributes = { type?: string; crossorigin?: JSX.HTMLCrossorigin; integrity?: string; @@ -20,7 +17,50 @@ export type PreloadLink = { media?: string; }; -/** Static asset manifest produced by a build (e.g. parsed Vite manifest.json). */ +/** + * An explicit `` emitted by the SSR asset pipeline. + * + * `as` is the HTML Standard's set of preload destinations exactly — anything + * else translates to null and the browser does nothing with the link. + * + * `imagesrcset` candidates must already be resolved by the integration: they + * are carried verbatim (a relative candidate resolves against the DOCUMENT + * URL, not the manifest base). Pair it with `imagesizes` whenever a candidate + * uses a width descriptor, which the spec requires — without it the source + * size falls back to `100vw` and the preload can miss the image the `` + * selects. Omitting `href` is the spec's own recommendation for the source-set + * form: it would only serve browsers without `imagesrcset` support, and there + * it would likely preload the wrong candidate. + */ +export type PreloadLink = PreloadLinkAttributes & + ( + | { + href: string; + as: Exclude; + imagesrcset?: never; + imagesizes?: never; + } + | { + href: string; + as: "image"; + imagesrcset?: string; + imagesizes?: string; + } + | { + href?: never; + as: "image"; + imagesrcset: string; + imagesizes?: string; + } + ); + +/** + * Static asset graph consumed by the SSR pipeline. This is Solid's own + * contract — a parsed Vite client manifest satisfies it structurally + * (unknown fields pass through untyped), but any bundler integration can + * produce it. Only these fields are ever read: `preloads` is Solid's + * extension slot for explicit typed links the integration selects. + */ export type AssetManifest = Record< string, { diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 086808164..72207eb11 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -49,6 +49,7 @@ import { evalHeadProps, evalHeadValue, resourceIdentity, + asciiLowerCase, replaceableIdentity, resolveHead, STYLESHEET_FETCH_META @@ -60,10 +61,7 @@ import { SerializerPlugin } from "../serialization/src/serializer-decode.js"; type MountableElement = Element | Document | ShadowRoot | DocumentFragment | Node; -/** An explicit `` emitted by the SSR asset pipeline. */ -export type PreloadLink = { - href: string; - as: JSX.HTMLPreloadAs; +type PreloadLinkAttributes = { type?: string; crossorigin?: JSX.HTMLCrossorigin; integrity?: string; @@ -72,7 +70,50 @@ export type PreloadLink = { media?: string; }; -/** Static asset manifest produced by a build (e.g. parsed Vite manifest.json). */ +/** + * An explicit `` emitted by the SSR asset pipeline. + * + * `as` is the HTML Standard's set of preload destinations exactly — anything + * else translates to null and the browser does nothing with the link. + * + * `imagesrcset` candidates must already be resolved by the integration: they + * are carried verbatim (a relative candidate resolves against the DOCUMENT + * URL, not the manifest base). Pair it with `imagesizes` whenever a candidate + * uses a width descriptor, which the spec requires — without it the source + * size falls back to `100vw` and the preload can miss the image the `` + * selects. Omitting `href` is the spec's own recommendation for the source-set + * form: it would only serve browsers without `imagesrcset` support, and there + * it would likely preload the wrong candidate. + */ +export type PreloadLink = PreloadLinkAttributes & + ( + | { + href: string; + as: Exclude; + imagesrcset?: never; + imagesizes?: never; + } + | { + href: string; + as: "image"; + imagesrcset?: string; + imagesizes?: string; + } + | { + href?: never; + as: "image"; + imagesrcset: string; + imagesizes?: string; + } + ); + +/** + * Static asset graph consumed by the SSR pipeline. This is Solid's own + * contract — a parsed Vite client manifest satisfies it structurally + * (unknown fields pass through untyped), but any bundler integration can + * produce it. Only these fields are ever read: `preloads` is Solid's + * extension slot for explicit typed links the integration selects. + */ export type AssetManifest = Record< string, { @@ -293,6 +334,57 @@ function joinAssetPath(base, file) { return base + (file[0] === "/" ? file.slice(1) : file); } +// Dev-only walks over a source set. Candidates are carried verbatim — rewriting +// them would put a srcset parser on the render path — so these only report. +// +// The scan follows the shape of the spec's srcset parser rather than splitting +// on commas: a candidate's URL is a maximal run of non-whitespace, and only the +// commas TRAILING that run separate it from the next candidate. Commas INSIDE a +// URL (`/w,400/hero.avif`, the shape image CDNs emit) are part of it, so they no +// longer read as two relative candidates. +// `visit(url, descriptors)` sees each candidate's URL and the descriptor text +// between it and the closing comma, so a descriptor check never scans a URL. +function eachCandidate(srcset, visit) { + let i = 0; + const n = srcset.length; + while (i < n) { + while (i < n && /[\s,]/.test(srcset[i])) i++; + const start = i; + while (i < n && !/\s/.test(srcset[i])) i++; + if (i === start) return false; + const raw = srcset.slice(start, i); + const url = raw.replace(/,+$/, ""); + let descriptors = ""; + // A URL that did not end in commas is followed by its descriptor; the + // comma that closes this candidate comes after it. + if (url === raw) { + const from = i; + while (i < n && srcset[i] !== ",") i++; + descriptors = srcset.slice(from, i); + } + if (url && visit(url, descriptors)) return true; + } + return false; +} + +// `href` is joined with `_base`; candidates are not. A relative candidate +// resolves against the DOCUMENT url instead, so the two point at different +// places on any route below the root — which is true whether or not `_base` is +// set, hence no base guard here. +function hasRelativeCandidate(srcset) { + return eachCandidate(srcset, url => !/^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|\/)/.test(url)); +} + +// Whether any candidate carries a width descriptor (`400w`), which the spec +// makes `imagesizes` mandatory for. Only descriptor text is examined: a URL is +// free-form and `https://cdn.example/image,400w 1x` is one density candidate, +// not a width descriptor. +function hasWidthDescriptor(srcset) { + return eachCandidate(srcset, (_, descriptors) => + /(?:^|\s)\d+(?:\.\d+)?w(?=\s|$)/.test(descriptors) + ); +} + function resolveAssets(moduleUrl, manifest) { if (!manifest) return null; const base = manifest._base; @@ -312,9 +404,26 @@ function resolveAssets(moduleUrl, manifest) { if (e.preloads) { for (let i = 0; i < e.preloads.length; i++) { const link = e.preloads[i]; - if (!link || typeof link.href !== "string" || !link.href) continue; + const href = link && typeof link.href === "string" && link.href; + const srcset = link && typeof link.imagesrcset === "string" && link.imagesrcset; + if (!href && !srcset) continue; if (!preloads) preloads = []; - preloads.push({ ...link, href: joinAssetPath(base, link.href) }); + if (href) preloads.push({ ...link, href: joinAssetPath(base, href) }); + else { + // A source-set link needs no href, but an unusable one must not + // reach `ResolvedAssets.preloads`, whose href is typed as a string. + const { href: bad, ...rest } = link; + if ("_SOLID_DEV_" && bad !== undefined) + console.warn("Preload href must be a non-empty string; dropping it.", bad); + preloads.push(rest); + } + if ("_SOLID_DEV_" && srcset && hasRelativeCandidate(srcset)) + console.warn( + "imagesrcset candidates are not joined with the manifest base — they resolve " + + "against the document URL, so a relative candidate points somewhere else " + + "than the joined href; the integration should emit resolved URLs.", + srcset + ); } } if (e.imports) for (let i = 0; i < e.imports.length; i++) walk(e.imports[i]); @@ -515,19 +624,36 @@ function isCssUrl(url) { return (q === -1 ? url : url.slice(0, q)).endsWith(".css"); } +const RESPONSIVE_ATTRIBUTES = ["imagesrcset", "imagesizes"]; + +// "Was this attribute supplied?" — `""` and `false` both mean no. Only the +// responsive pair uses it: `crossorigin: ""` is a real value (anonymous), +// so the emission loop cannot apply this test globally. +function isSetAttr(value) { + return value != null && value !== false && value !== ""; +} + const PRELOAD_LINK_ATTRIBUTES = [ "type", "crossorigin", "integrity", "referrerpolicy", "fetchpriority", - "media" + "media", + "imagesrcset", + "imagesizes" ]; // Normalize once for document/frame output and dedupe with useHead resources. +// +// Order matters: the destination decides whether the source set counts as a +// source at all, so `as` is resolved and the responsive pair normalized BEFORE +// the "has a source" check. Doing it the other way round accepted an +// `imagesrcset` on a non-image destination as the source, then filtered that +// same attribute off, and emitted a sourceless ``. function registerPreloadLink(tracking, headRegistry, link, nonce) { - if (!link || typeof link !== "object" || typeof link.href !== "string" || !link.href) { - if ("_SOLID_DEV_") console.warn('registerAsset("preload") requires a non-empty string href.'); + if (!link || typeof link !== "object") { + if ("_SOLID_DEV_") console.warn('registerAsset("preload") requires a descriptor object.', link); return null; } if (typeof link.as !== "string") { @@ -536,6 +662,10 @@ function registerPreloadLink(tracking, headRegistry, link, nonce) { } const as = asciiLowerCase(link.as); let destination = null; + // The HTML Standard's preload destinations, exactly: "A preload destination + // is 'fetch', 'font', 'image', 'script', 'style', or 'track'." Anything else + // translates to null and the preload does nothing, so it is rejected here + // rather than emitted as a link no browser will act on. switch (as) { case "script": case "style": @@ -553,9 +683,65 @@ function registerPreloadLink(tracking, headRegistry, link, nonce) { ); return null; } - const props = { rel: "preload", href: link.href, as }; + // Responsive attributes are image-only. A non-image link carrying one is + // an authoring mistake, not a reason to drop a render-critical preload: + // the attribute is filtered out and the link still ships. `""` counts as + // absent, so an integration emitting `imagesrcset: srcsetFor(file)` for + // every asset keeps its script and style links. A non-string value is + // filtered too — `String(42)` would ship `imagesrcset="42"`, a source set + // no browser can parse, and forge a resource identity out of garbage. + const responsive = as === "image"; + if ("_SOLID_DEV_" && !responsive && (isSetAttr(link.imagesrcset) || isSetAttr(link.imagesizes))) + console.warn( + 'registerAsset("preload") only supports imagesrcset and imagesizes with as="image".' + ); + let srcset = null; + let sizes = null; + if (responsive) { + for (const name of RESPONSIVE_ATTRIBUTES) { + const value = link[name]; + if (!isSetAttr(value)) continue; + if (typeof value !== "string") { + if ("_SOLID_DEV_") console.warn(`registerAsset("preload") expects a string ${name}.`); + continue; + } + if (name === "imagesrcset") srcset = value; + else sizes = value; + } + } + const href = typeof link.href === "string" && link.href ? link.href : null; + if ("_SOLID_DEV_" && !href && link.href != null && link.href !== false) + console.warn("Preload href must be a non-empty string; dropping it.", link.href); + // Spec: "One or both of the href or imagesrcset attributes must be present." + // A source set only counts once it survived the image-only filter above. + if (!href && !srcset) { + if ("_SOLID_DEV_") + console.warn('registerAsset("preload") requires a non-empty string href or imagesrcset.'); + return null; + } + // Spec: "If the imagesrcset attribute is present and has any image candidate + // strings using a width descriptor, the imagesizes attribute must also be + // present." Without it the source size defaults to 100vw, so a preload meant + // for a narrower slot silently fetches the wrong candidate and the + // downloads a second one. + if ("_SOLID_DEV_" && srcset && !sizes && hasWidthDescriptor(srcset)) + console.warn( + "imagesrcset uses a width descriptor, so imagesizes is required; without it the " + + "source size defaults to 100vw and the preload may not match the image.", + srcset + ); + const props = { rel: "preload" }; + if (href) props.href = href; + props.as = as; for (let i = 0; i < PRELOAD_LINK_ATTRIBUTES.length; i++) { const name = PRELOAD_LINK_ATTRIBUTES[i]; + if (RESPONSIVE_ATTRIBUTES.indexOf(name) !== -1) { + // Normalized above: an empty or non-string source set is not a source + // set, and emitting one would fork the resource identity. + const value = name === "imagesrcset" ? srcset : sizes; + if (value !== null) props[name] = value; + continue; + } const value = link[name]; if (value == null || value === false) continue; props[name] = value === true ? "" : String(value); @@ -1066,12 +1252,6 @@ export function styleNonce(nonce) { return destinationNonce(nonce, "style"); } -// HTML compares rel/as ASCII case-insensitively; toLowerCase would fold a -// non-ASCII character onto an ASCII one the parser never matches. -function asciiLowerCase(value) { - return value.replace(/[A-Z]/g, c => String.fromCharCode(c.charCodeAt(0) + 32)); -} - // Attribute names are ASCII case-insensitive, so a caller-supplied `Nonce` // counts as one too. function hasNonceProp(props) { @@ -2147,6 +2327,11 @@ export function renderToStream(code, options = {}) { // Shell head flush: commits every registration not owned by a // still-pending fragment (those flush with their fragment later). const head = renderShellHead(headRegistry, nonce, k => registry.has(k), noScripts); + // `preloads`, `preloadLinks` and `inlineStyles` are the LIVE tracking + // containers, not snapshots: a post-shell registration pushes into them + // AND arrives separately through `sink.asset`. Consume them inside this + // call (the document sink splices the head synchronously) — a sink that + // stores the meta and re-reads it later sees late entries twice. sink.shell(resolveSSRSelectValues(html), { preloads: tracking.emittedAssets, preloadLinks: tracking.preloadLinks, diff --git a/packages/web/test/preload-links-adopt-client.spec.tsx b/packages/web/test/preload-links-adopt-client.spec.tsx new file mode 100644 index 000000000..8e65e3e12 --- /dev/null +++ b/packages/web/test/preload-links-adopt-client.spec.tsx @@ -0,0 +1,123 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ + +// Mount-once head resources adopt the element the server already emitted +// instead of appending a second one. A responsive image preload has no href +// — the source set is the request — so adoption has to match on a null href +// plus the identity qualifiers, the same rule the frame client applies. +import { describe, expect, test, afterEach } from "vitest"; +import { render, useHead } from "../src/index.js"; + +const preloads = () => document.head.querySelectorAll('link[rel="preload"]'); + +function serverEmitted(attrs: Record) { + const link = document.createElement("link"); + link.rel = "preload"; + for (const name in attrs) link.setAttribute(name, attrs[name]); + document.head.appendChild(link); + return link; +} + +function mount(props: Record) { + return render(() => { + useHead({ tag: "link", props: { rel: "preload", ...props } }); + return null; + }, document.createElement("div")); +} + +afterEach(() => { + for (const link of Array.from(preloads())) link.remove(); +}); + +describe("preload link adoption on the client", () => { + test("adopts a server-emitted link that carries an href", () => { + const server = serverEmitted({ href: "/hero.avif", as: "image" }); + mount({ href: "/hero.avif", as: "image" }); + + expect(preloads()).toHaveLength(1); + expect(preloads()[0]).toBe(server); + }); + + test("adopts a source-set link that has no href", () => { + const server = serverEmitted({ + as: "image", + imagesrcset: "/card-400.avif 400w, /card-800.avif 800w", + imagesizes: "100vw" + }); + mount({ + as: "image", + imagesrcset: "/card-400.avif 400w, /card-800.avif 800w", + imagesizes: "100vw" + }); + + expect(preloads()).toHaveLength(1); + expect(preloads()[0]).toBe(server); + }); + + test("does not adopt across a different source set", () => { + serverEmitted({ as: "image", imagesrcset: "/a.avif 1x" }); + mount({ as: "image", imagesrcset: "/b.avif 2x" }); + + expect(preloads()).toHaveLength(2); + }); + + test("does not adopt an href-bearing link for a source-set request", () => { + // The fallback href makes these different declarations, and the server + // identity already treats them as two resources. + serverEmitted({ href: "/card.avif", as: "image", imagesrcset: "/card-2x.avif 2x" }); + mount({ as: "image", imagesrcset: "/card-2x.avif 2x" }); + + expect(preloads()).toHaveLength(2); + }); + + test("does not adopt across a different destination", () => { + serverEmitted({ href: "/a.bin", as: "fetch", crossorigin: "anonymous" }); + mount({ href: "/a.bin", as: "font", crossorigin: "anonymous" }); + + expect(preloads()).toHaveLength(2); + }); + + test("adopts across equivalent CORS spellings", () => { + // The server writes the value it was handed; an author writes whichever + // spelling they prefer. Both are the Anonymous state, so this is one + // request and adoption has to see it as one. + const server = serverEmitted({ href: "/f.woff2", as: "font", crossorigin: "" }); + mount({ href: "/f.woff2", as: "font", crossorigin: "anonymous" }); + + expect(preloads()).toHaveLength(1); + expect(preloads()[0]).toBe(server); + }); + + test("adopts a bare crossorigin attribute for an authored anonymous", () => { + const server = serverEmitted({ href: "/g.woff2", as: "font", crossorigin: "ANONYMOUS" }); + mount({ href: "/g.woff2", as: "font", crossorigin: true }); + + expect(preloads()).toHaveLength(1); + expect(preloads()[0]).toBe(server); + }); + + test("does not adopt across a different credentials mode", () => { + serverEmitted({ href: "/h.woff2", as: "font", crossorigin: "anonymous" }); + mount({ href: "/h.woff2", as: "font", crossorigin: "use-credentials" }); + + expect(preloads()).toHaveLength(2); + }); + + test("adopts when a falsy conditional stands in for an absent qualifier", () => { + const server = serverEmitted({ href: "/i.avif", as: "image" }); + mount({ href: "/i.avif", as: "image", crossorigin: false, media: false }); + + expect(preloads()).toHaveLength(1); + expect(preloads()[0]).toBe(server); + }); + + test("adopts across destination case and filtered responsive values", () => { + const server = serverEmitted({ href: "/j.avif", as: "image" }); + mount({ href: "/j.avif", as: "IMAGE", imagesrcset: "", imagesizes: "" }); + + expect(preloads()).toHaveLength(1); + expect(preloads()[0]).toBe(server); + }); +}); diff --git a/packages/web/test/preload-links-frame-client.spec.ts b/packages/web/test/preload-links-frame-client.spec.ts index e311b5049..d245b3e09 100644 --- a/packages/web/test/preload-links-frame-client.spec.ts +++ b/packages/web/test/preload-links-frame-client.spec.ts @@ -24,14 +24,29 @@ describe("frame preload links", () => { href: "/shared.bin", attrs: { as: "image", type: "image/avif", fetchpriority: "high" } }, - { href: "/shared.bin", attrs: { as: "fetch", crossorigin: "anonymous" } } + { href: "/shared.bin", attrs: { as: "fetch", crossorigin: "anonymous" } }, + { + href: "/card-fallback.avif", + attrs: { + as: "image", + imagesrcset: "/card.avif 1x, /card@2x.avif 2x", + imagesizes: "20rem" + } + }, + { + attrs: { + as: "image", + imagesrcset: "/card.avif 1x, /card@2x.avif 2x", + imagesizes: "20rem" + } + } ] } } }); let links = [...document.head.querySelectorAll('link[rel="preload"]')]; - expect(links).toHaveLength(2); + expect(links).toHaveLength(4); expect(links.find(link => link.getAttribute("as") === "image")?.getAttribute("type")).toBe( "image/avif" ); @@ -41,6 +56,16 @@ describe("frame preload links", () => { expect( links.find(link => link.getAttribute("as") === "fetch")?.getAttribute("crossorigin") ).toBe("anonymous"); + expect( + document.head.querySelectorAll( + 'link[rel="preload"][imagesrcset="/card.avif 1x, /card@2x.avif 2x"]' + ) + ).toHaveLength(2); + expect( + document.head.querySelector( + 'link[rel="preload"][imagesrcset="/card.avif 1x, /card@2x.avif 2x"]:not([href])' + ) + ).not.toBeNull(); const lateAssets = { type: "assets", @@ -65,11 +90,92 @@ describe("frame preload links", () => { ); expect(links).toHaveLength(1); + frame.apply({ + version: 1, + r: { + "seg:responsive:assets": { + type: "assets", + key: "responsive", + preloads: [ + { + attrs: { + as: "image", + imagesrcset: "/card.avif 1x, /card@2x.avif 2x", + imagesizes: "20rem" + } + } + ] + } + } + }); + expect( + document.head.querySelectorAll( + 'link[rel="preload"][imagesrcset="/card.avif 1x, /card@2x.avif 2x"]' + ) + ).toHaveLength(2); + document.head.querySelector('link[href="/late.woff2"]')!.remove(); frame.apply({ version: 2, r: { "seg::assets": lateAssets } }); expect(document.head.querySelector('link[href="/late.woff2"]')).not.toBeNull(); }); + it("adopts a document preload across equivalent CORS spellings", () => { + // A frame chunk carries whichever spelling the server was handed; the + // document may already hold the other. Both are the Anonymous state. + const server = document.createElement("link"); + server.rel = "preload"; + server.setAttribute("href", "/f.woff2"); + server.setAttribute("as", "font"); + server.setAttribute("crossorigin", "anonymous"); + document.head.appendChild(server); + + const boundary = document.createElement("div"); + document.body.appendChild(boundary); + createFrame(boundary).apply({ + version: 1, + r: { + "seg::assets": { + type: "assets", + key: "", + preloads: [ + { href: "/f.woff2", attrs: { as: "font", crossorigin: "" } }, + // A different credentials mode is a different request. + { href: "/f.woff2", attrs: { as: "font", crossorigin: "use-credentials" } } + ] + } + } + }); + + expect(document.head.querySelectorAll('link[href="/f.woff2"]')).toHaveLength(2); + expect(document.head.querySelector('link[crossorigin="anonymous"]')).toBe(server); + }); + + it("adopts across destination case and filtered responsive values", () => { + const server = document.createElement("link"); + server.rel = "preload"; + server.setAttribute("href", "/j.avif"); + server.setAttribute("as", "IMAGE"); + server.setAttribute("imagesrcset", ""); + server.setAttribute("imagesizes", ""); + document.head.appendChild(server); + + const boundary = document.createElement("div"); + document.body.appendChild(boundary); + createFrame(boundary).apply({ + version: 1, + r: { + "seg::assets": { + type: "assets", + key: "", + preloads: [{ href: "/j.avif", attrs: { as: "image" } }] + } + } + }); + + expect(document.head.querySelectorAll('link[href="/j.avif"]')).toHaveLength(1); + expect(document.head.querySelector('link[href="/j.avif"]')).toBe(server); + }); + it("retains every late root asset record until a frame registers", () => { const host = createFrameHost(); host.apply({ diff --git a/packages/web/test/preload-links.type-tests.ts b/packages/web/test/preload-links.type-tests.ts index e77c41f87..1f691b661 100644 --- a/packages/web/test/preload-links.type-tests.ts +++ b/packages/web/test/preload-links.type-tests.ts @@ -17,12 +17,33 @@ const resolved: ResolvedAssets = { js: [], css: [], preloads: [preload] }; void manifest; void resolved; -// @ts-expect-error href is required until responsive image preloads add a source-only form -const missingHref: PreloadLink = { as: "image" }; +const responsiveWithFallback: PreloadLink = { + href: "/hero.avif", + as: "image", + imagesrcset: "/hero.avif 1x, /hero@2x.avif 2x", + imagesizes: "50vw" +}; +const responsiveWithoutFallback: PreloadLink = { + as: "image", + imagesrcset: "/hero-400.avif 400w, /hero-800.avif 800w", + imagesizes: "100vw" +}; +void responsiveWithFallback; +void responsiveWithoutFallback; + +// @ts-expect-error href or imagesrcset is required +const missingSource: PreloadLink = { as: "image" }; // @ts-expect-error only HTML preload destinations are accepted const invalidDestination: PreloadLink = { href: "/movie.mp4", as: "video" }; // @ts-expect-error arbitrary priorities are rejected const invalidPriority: PreloadLink = { href: "/hero.avif", as: "image", fetchpriority: "urgent" }; -void missingHref; +// @ts-expect-error responsive image attributes require as="image" +const invalidResponsiveDestination: PreloadLink = { + href: "/app.js", + as: "script", + imagesrcset: "/app@2x.js 2x" +}; +void missingSource; void invalidDestination; void invalidPriority; +void invalidResponsiveDestination; diff --git a/packages/web/test/runtime/preload-links.spec.js b/packages/web/test/runtime/preload-links.spec.js index 86c7d9753..d96f0847c 100644 --- a/packages/web/test/runtime/preload-links.spec.js +++ b/packages/web/test/runtime/preload-links.spec.js @@ -36,7 +36,19 @@ describe("typed preload links", () => { isEntry: true, preloads: [ { href: "", as: "image" }, - { href: "hero.avif", as: "image", type: "image/avif", fetchpriority: "high" } + { + href: "hero.avif", + as: "image", + type: "image/avif", + fetchpriority: "high", + imagesrcset: "/cdn/hero.avif 1x, /cdn/hero@2x.avif 2x", + imagesizes: "50vw" + }, + { + as: "image", + imagesrcset: "/cdn/hero-400.avif 400w, /cdn/hero-800.avif 800w", + imagesizes: "100vw" + } ] }, "shared.tsx": { @@ -66,7 +78,19 @@ describe("typed preload links", () => { ); expect(resolved.preloads).toEqual([ - { href: "/assets/hero.avif", as: "image", type: "image/avif", fetchpriority: "high" }, + { + href: "/assets/hero.avif", + as: "image", + type: "image/avif", + fetchpriority: "high", + imagesrcset: "/cdn/hero.avif 1x, /cdn/hero@2x.avif 2x", + imagesizes: "50vw" + }, + { + as: "image", + imagesrcset: "/cdn/hero-400.avif 400w, /cdn/hero-800.avif 800w", + imagesizes: "100vw" + }, { href: "/assets/fonts/app.woff2?v=1", as: "font", @@ -75,13 +99,18 @@ describe("typed preload links", () => { } ]); expect(html).toContain( - '' + '' + ); + expect(html).toContain( + '' ); expect(html).toContain( '' ); expect(html).not.toContain("hidden.webp"); expect(html).not.toContain("not-automatically-preloaded.png"); + resolved.preloads[1].imagesizes = "50vw"; + expect(manifest["app.tsx"].preloads[2].imagesizes).toBe("100vw"); }); it("preserves fetch metadata and dedupes by resource identity", () => { @@ -94,7 +123,9 @@ describe("typed preload links", () => { integrity: "sha384-image", referrerpolicy: "no-referrer", fetchpriority: "high", - media: "(min-width: 60rem)" + media: "(min-width: 60rem)", + imagesrcset: "/hero.avif 1x, /hero@2x.avif 2x", + imagesizes: "50vw" }; ctx.registerAsset("preload", image); ctx.registerAsset("preload", image); @@ -116,11 +147,46 @@ describe("typed preload links", () => { expect(image).toContain('referrerpolicy="no-referrer"'); expect(image).toContain('fetchpriority="high"'); expect(image).toContain('media="(min-width: 60rem)"'); + expect(image).toContain('imagesrcset="/hero.avif 1x, /hero@2x.avif 2x"'); + expect(image).toContain('imagesizes="50vw"'); expect(image).not.toContain("nonce"); expect(html.match(/crossorigin=""/g)).toHaveLength(1); expect(html).not.toContain("sha384-conflict"); }); + it("renders and dedupes responsive image preloads without href", () => { + const link = { + as: "image", + imagesrcset: '/hero-small.avif 480w, /hero-large.avif?crop="wide" 960w', + imagesizes: "100vw" + }; + const other = { + ...link, + imagesrcset: "/other-small.avif 480w, /other-large.avif 960w" + }; + const html = r.renderToString(() => { + const ctx = sharedConfig.context; + ctx.registerAsset("preload", link); + ctx.registerAsset("preload", link); + r.useHead({ tag: "link", props: { rel: "preload", ...link } }); + ctx.registerAsset("preload", { ...link, href: "/hero-fallback.avif" }); + r.useHead({ tag: "link", props: { rel: "preload", ...other } }); + ctx.registerAsset("preload", other); + return r.ssr``; + }); + + expect(html.match(/imagesrcset=/g)).toHaveLength(3); + expect(html).toContain( + '' + ); + expect(html).toContain( + '' + ); + expect(html).toContain( + 'imagesrcset="/other-small.avif 480w, /other-large.avif 960w" imagesizes="100vw"' + ); + }); + it("routes nonces and delivers links through onHead", () => { let embeddedHead; const html = r.renderToString( @@ -175,6 +241,155 @@ describe("typed preload links", () => { expect(after.match(/href="\/after\.avif"/g)).toHaveLength(1); }); + it("keeps a link whose responsive attributes are empty rather than absent", () => { + // An integration emitting `imagesrcset: srcsetFor(file)` gets "" for + // everything that is not an image; that must not drop its script, + // style and font preloads. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const html = r.renderToString(() => { + const ctx = sharedConfig.context; + ctx.registerAsset("preload", { href: "/app.js", as: "script", imagesrcset: "" }); + ctx.registerAsset("preload", { href: "/app.css", as: "style", imagesizes: "" }); + ctx.registerAsset("preload", { + href: "/f.woff2", + as: "font", + crossorigin: "", + imagesrcset: 0 + }); + return r.ssr``; + }); + warn.mockRestore(); + + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).not.toContain("imagesrcset"); + expect(html).not.toContain("imagesizes"); + }); + + it("canonicalizes destinations and filtered responsive qualifiers", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const html = r.renderToString(() => { + const ctx = sharedConfig.context; + ctx.registerAsset("preload", { href: "/x.avif", as: "image" }); + r.useHead({ + tag: "link", + props: { + rel: "preload", + href: "/x.avif", + as: "IMAGE", + imagesrcset: "", + imagesizes: "" + } + }); + return r.ssr``; + }); + warn.mockRestore(); + + expect(html.match(/rel="preload"/g)).toHaveLength(1); + expect(html).not.toContain("imagesrcset"); + }); + + it("warns when srcset candidates cannot resolve against the manifest base", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + r.renderToString(() => r.ssr``, { + manifest: { + _base: "/assets/", + "app.tsx": { + file: "app.js", + isEntry: true, + preloads: [ + // Relative: `_base` joins href but never srcset candidates. + { as: "image", imagesrcset: "hero.avif 1x, hero@2x.avif 2x" }, + { as: "image", imagesrcset: "/cdn/a.avif 1x" }, + { as: "image", imagesrcset: "https://cdn.example/b.avif 1x" } + ] + } + } + }); + const warnings = warn.mock.calls.map(call => String(call[0])); + warn.mockRestore(); + + const baseWarnings = warnings.filter(m => m.includes("manifest base")); + expect(baseWarnings).toHaveLength(1); + }); + + it("reports a relative candidate with no manifest base, and spares commas in URLs", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + r.renderToString(() => r.ssr``, { + manifest: { + // No `_base`: joinAssetPath still answers "/hero.avif" while the + // candidate resolves against the document URL, so the asymmetry the + // warning exists for is present exactly as it is with a real base. + "app.tsx": { + file: "app.js", + isEntry: true, + preloads: [ + { href: "hero.avif", as: "image", imagesrcset: "hero.avif 400w", imagesizes: "50vw" }, + // Commas inside a candidate URL are part of the URL, not + // separators — the shape Cloudinary/imgproxy/Fastly IO emit. + { + as: "image", + imagesrcset: "https://cdn.example/w,400/a.avif 400w, /local/w,800/b.avif 800w", + imagesizes: "50vw" + }, + { as: "image", imagesrcset: "/cdn/c-1x.avif, /cdn/c-2x.avif 2x" } + ] + } + } + }); + const warnings = warn.mock.calls.map(call => String(call[0])); + warn.mockRestore(); + + expect(warnings.filter(m => m.includes("manifest base"))).toHaveLength(1); + }); + + it("drops an unusable href from a source-set entry instead of the entry", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let resolved; + r.renderToString( + () => { + resolved = sharedConfig.context.resolveAssets("app.tsx"); + return r.ssr``; + }, + { + manifest: { + "app.tsx": { + file: "app.js", + preloads: [ + { href: "", as: "image", imagesrcset: "/a.avif 1x" }, + { href: null, as: "image", imagesrcset: "/b.avif 1x" }, + { href: 5, as: "image", imagesrcset: "/c.avif 1x" } + ] + } + } + } + ); + warn.mockRestore(); + + // `ResolvedAssets.preloads` types href as a string; a bad one is omitted + // rather than taking a working source-set link down with it. + expect(resolved.preloads).toHaveLength(3); + for (const link of resolved.preloads) expect("href" in link).toBe(false); + expect(resolved.preloads[0].imagesrcset).toBe("/a.avif 1x"); + }); + + it("warns for a non-string responsive attribute on an image link", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + r.renderToString(() => { + sharedConfig.context.registerAsset("preload", { + href: "/a.avif", + as: "image", + imagesrcset: 42 + }); + return r.ssr``; + }); + const warnings = warn.mock.calls.map(call => String(call[0])); + warn.mockRestore(); + + expect(warnings.filter(m => m.includes("string imagesrcset"))).toHaveLength(1); + }); + it("warns for request-mode mismatches and rejects invalid descriptors", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); let html; @@ -189,6 +404,17 @@ describe("typed preload links", () => { ctx.registerAsset("preload", "/untyped.bin"); ctx.registerAsset("preload", { href: "/missing-as.bin" }); ctx.registerAsset("preload", { href: "/invalid.bin", as: " style " }); + ctx.registerAsset("preload", { + href: "/script.js", + as: "script", + imagesrcset: "/script-2x.js 2x" + }); + ctx.registerAsset("preload", { + href: "/style.css", + as: "style", + imagesizes: "100vw" + }); + ctx.registerAsset("preload", { href: "", as: "image", imagesrcset: "" }); return r.ssr``; }); } finally { @@ -199,11 +425,90 @@ describe("typed preload links", () => { expect(html).not.toContain("untyped.bin"); expect(html).not.toContain("missing-as.bin"); expect(html).not.toContain("invalid.bin"); - expect(warnings).toHaveLength(5); + // A responsive attribute on a non-image destination is an authoring + // mistake, not a reason to drop a render-critical preload: the attribute + // is filtered, the link still ships. + expect(html).not.toContain("script-2x.js"); + expect(html).not.toContain("imagesizes"); + expect(html).toContain(''); + expect(html).toContain(''); + // The last descriptor is wrong twice over — an unusable href AND no + // surviving source — and says so, rather than reporting only the second. + expect(warnings).toHaveLength(9); const corsWarnings = warnings.filter(message => message.includes("crossorigin")); expect(corsWarnings).toHaveLength(2); }); + it("drops a link whose only source was a filtered responsive attribute", () => { + // The destination decides whether a source set is a source at all. Reading + // `imagesrcset` as one before `as` is known accepted these descriptors and + // then filtered the attribute away, emitting `` — a link with nothing to fetch. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const html = r.renderToString(() => { + const ctx = sharedConfig.context; + ctx.registerAsset("preload", { as: "script", imagesrcset: "/x.js 2x" }); + ctx.registerAsset("preload", { as: "font", imagesrcset: "/f.woff2 1x" }); + ctx.registerAsset("preload", { as: "style", imagesizes: "100vw" }); + // An image keeps the source-set-only form. + ctx.registerAsset("preload", { as: "image", imagesrcset: "/hero.avif 1x" }); + return r.ssr``; + }); + warn.mockRestore(); + + expect(html.match(/rel="preload"/g)).toHaveLength(1); + expect(html).toContain(''); + }); + + it("filters non-string responsive values instead of coercing them", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const html = r.renderToString(() => { + const ctx = sharedConfig.context; + ctx.registerAsset("preload", { + href: "/hero.avif", + as: "image", + imagesrcset: 42, + imagesizes: {} + }); + // Nothing left to fetch once the junk source set is filtered. + ctx.registerAsset("preload", { as: "image", imagesrcset: 42 }); + return r.ssr``; + }); + const warnings = warn.mock.calls.map(call => String(call[0])); + warn.mockRestore(); + + expect(html).toContain(''); + expect(html).not.toContain("imagesrcset"); + expect(html).not.toContain("[object Object]"); + expect(html.match(/rel="preload"/g)).toHaveLength(1); + expect(warnings.filter(m => m.includes("string imagesrcset"))).toHaveLength(2); + expect(warnings.filter(m => m.includes("string imagesizes"))).toHaveLength(1); + }); + + it("warns when a width descriptor ships without imagesizes", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + r.renderToString(() => { + const ctx = sharedConfig.context; + ctx.registerAsset("preload", { as: "image", imagesrcset: "/a-400.avif 400w" }); + // Paired, and density-only art direction, are both fine. + ctx.registerAsset("preload", { + as: "image", + imagesrcset: "/b-400.avif 400w", + imagesizes: "50vw" + }); + ctx.registerAsset("preload", { as: "image", imagesrcset: "/c-1x.avif, /c-2x.avif 2x" }); + ctx.registerAsset("preload", { + as: "image", + imagesrcset: "https://cdn.example/image,400w 1x" + }); + return r.ssr``; + }); + const warnings = warn.mock.calls.map(call => String(call[0])); + warn.mockRestore(); + + expect(warnings.filter(m => m.includes("width descriptor"))).toHaveLength(1); + }); + it("writes a link registered after the shell to the document stream", async () => { let done; const html = await pipeToString( @@ -238,6 +543,11 @@ describe("typed preload links", () => { const ctx = sharedConfig.context; ctx.registerAsset("module", "/entry.js"); ctx.registerAsset("preload", { href: "/hero.webp", as: "image" }); + ctx.registerAsset("preload", { + as: "image", + imagesrcset: "/hero-480.webp 480w, /hero-960.webp 960w", + imagesizes: "100vw" + }); done = ctx.registerFragment("late"); setTimeout(() => { ctx.registerAsset("preload", { @@ -264,6 +574,16 @@ describe("typed preload links", () => { expect(meta.preloadLinks[0]).toEqual( expect.objectContaining({ href: "/hero.webp", attrs: { as: "image" } }) ); + expect(meta.preloadLinks[1]).toEqual( + expect.objectContaining({ + href: undefined, + attrs: { + as: "image", + imagesrcset: "/hero-480.webp 480w, /hero-960.webp 960w", + imagesizes: "100vw" + } + }) + ); expect(late).toEqual([ [ "preload", @@ -289,6 +609,11 @@ describe("typed preload links", () => { }); ctx.registerAsset("preload", { href: "/critical.js", as: "script" }); ctx.registerAsset("preload", { href: "/critical.css", as: "style" }); + ctx.registerAsset("preload", { + as: "image", + imagesrcset: "/hero-480.avif 480w, /hero-960.avif 960w", + imagesizes: "100vw" + }); return r.ssr`
app
`; }, { @@ -303,7 +628,14 @@ describe("typed preload links", () => { attrs: { as: "font", type: "font/woff2", crossorigin: "" } }, { href: "/critical.js", attrs: { as: "script", nonce: "script-nonce" } }, - { href: "/critical.css", attrs: { as: "style", nonce: "style-nonce" } } + { href: "/critical.css", attrs: { as: "style", nonce: "style-nonce" } }, + { + attrs: { + as: "image", + imagesrcset: "/hero-480.avif 480w, /hero-960.avif 960w", + imagesizes: "100vw" + } + } ]); let done; @@ -343,4 +675,72 @@ describe("typed preload links", () => { } ]); }); + + it("treats equivalent CORS spellings as one request", () => { + // The CORS settings attribute is three states, not a string range: absent, + // Use Credentials, and Anonymous (every other present value, including an + // invalid one). Forking on spelling shipped the same font five times. + const html = r.renderToString(() => { + const ctx = sharedConfig.context; + for (const crossorigin of ["", true, "anonymous", "ANONYMOUS", "bogus"]) + ctx.registerAsset("preload", { href: "/f.woff2", as: "font", crossorigin }); + for (const crossorigin of ["use-credentials", "USE-CREDENTIALS"]) + ctx.registerAsset("preload", { href: "/f.woff2", as: "font", crossorigin }); + ctx.registerAsset("preload", { href: "/f.woff2", as: "font" }); + return r.ssr``; + }); + + // Anonymous, Use Credentials, No CORS — three requests, three links. + expect(html.match(/rel="preload"/g)).toHaveLength(3); + expect(html).toContain(''); + expect(html).toContain( + '' + ); + expect(html).toContain(''); + }); + + it("treats a falsy conditional qualifier as an absent one", () => { + // `crossorigin={cond && "anonymous"}` is an everyday JSX idiom; both + // attribute writers drop `false`, so the identity must too or the same + // link ships twice with byte-identical markup. + const html = r.renderToString(() => { + sharedConfig.context.registerAsset("preload", { href: "/y.avif", as: "image" }); + r.useHead({ + tag: "link", + props: { rel: "preload", href: "/y.avif", as: "image", crossorigin: false, media: false } + }); + return r.ssr``; + }); + + expect(html.match(/rel="preload"/g)).toHaveLength(1); + }); + + it("keeps qualifier values from forging one another", () => { + // `:q=value` concatenation let a value carrying the delimiters look like a + // different qualifier set, which silently dropped the second resource. + const html = r.renderToString(() => { + const ctx = sharedConfig.context; + ctx.registerAsset("preload", { href: "/x.avif", as: "image", type: "a:media=b" }); + ctx.registerAsset("preload", { href: "/x.avif", as: "image", type: "a", media: "b" }); + return r.ssr``; + }); + + expect(html.match(/rel="preload"/g)).toHaveLength(2); + }); + + it("keeps URLs from forging qualifier fields", () => { + const html = r.renderToString(() => { + r.useHead({ + tag: "link", + props: { rel: "stylesheet", href: "/loader:type=8:text/css" } + }); + r.useHead({ + tag: "link", + props: { rel: "stylesheet", href: "/loader", type: "text/css" } + }); + return r.ssr``; + }); + + expect(html.match(/rel="stylesheet"/g)).toHaveLength(2); + }); }); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index fe96c68f2..329db24d8 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -319,7 +319,15 @@ module.exports = [ // Patch-channel removal (2026-09-02): 10.86 -> 10.73 KB, measured at // 10.70. The channel is deleted from next — regions own value delivery, // the unified-For design owns structure — reclaiming the core-retained emission seams. - limit: "10.73 KB", + // Preload identity canonicalization, rebased onto next (2026-09-02): + // 10.73 -> 10.74 KB, measured at 10.731 against next's 10.700 with only + // dist/web.js swapped. Not retained code: the tree-shaken bundle is + // byte-identical and web.js contributes the same 7106 minified bytes on + // both sides. head.ts gains two top-level helpers this bundle never + // reaches (asciiLowerCase, qualifierValue), which shifts esbuild's + // identifier allocation over the same-length output — brotli layout + // drift, 31 B. Ratcheted to the next 0.01 kB per this file's rule. + limit: "10.74 KB", modifyEsbuildConfig }, { @@ -374,6 +382,13 @@ module.exports = [ // 17.673. Hydration seeds the applied-class snapshot without mutating // the claimed DOM so the first live in-place change still diffs. // + // Responsive image preloads (2026-09-01): 17.56 -> 17.59 KB, measured at + // 17.570 (+29 B). The one document scenario that pays: it retains + // `lazy`, so the whole asset-registration path is reachable and it picks + // up the source-set branch in mountHeadResource. csr-app moved the other + // way on brotli layout (see its note); the identity commit before this + // one was byte-neutral in every document bundle. + // // Patch-channel removal (2026-09-02): 17.72 -> 17.61 KB, measured at // 17.58. The channel is deleted from next — regions own value delivery, // the unified-For design owns structure — reclaiming the insert $ll seam and core emission bytes. @@ -447,6 +462,11 @@ module.exports = [ // revert path resyncs overlaid keysets for mapArray. This scenario // retains every store family, so it pays the whole module. Ruled // correctness-over-size in the #3164 thread; conscious bump. + // + // Typed responsive preloads (2026-09-01): byte-neutral, measured at + // 26.701 across the whole branch — the identity canonicalization shares + // one helper with the code it replaced, and this bundle does not retain + // the source-set adoption branch. path: "hydrating-store-app.js", // // Patch-channel removal (2026-09-02): 26.99 -> 26.15 KB, measured at @@ -481,6 +501,11 @@ module.exports = [ // 12.948. The one counter-mover: this bundle never retained the // scheduler-resident ledger (nothing to shake), so it pays only the // hook call site's second argument plus brotli layout drift. + // + // Responsive image preloads (2026-09-01): 23 B SMALLER, measured at + // 12.925 against 12.948. Brotli layout drift, not a real shrink — the + // preceding identity commit measured byte-identical here. Ceiling left + // where it is; ratchet it in a drift pass, not in a feature PR. path: "csr-app.js", // // Patch-channel removal (2026-09-02): 13.11 -> 12.97 KB, measured at @@ -505,12 +530,31 @@ module.exports = [ // Verified via metafile that the bundle is still exactly the two dist // files (no seroval creep — the regression this scenario guards). // - // Typed preload links: 11.1 -> 11.28 KB, measured at 11.269. Frames now - // preserve request metadata, adopt matching document links, and retain - // every late root asset record for mounts that register after the - // stream arrives. + // Typed preload links: 11.06 -> 11.27 KB measured on the rc.5 base + // (~210 B). Frames now preserve request metadata (ensurePreload + + // qualifier-aware head matching), adopt matching document links, and + // retain every late root asset record for mounts that register after + // the stream arrives. + // + // Responsive image preloads (2026-09-01): 11.34 -> 11.37 KB, measured at + // 11.360 (+40 B on top of the identity commit). Frame consumers locate + // and create a source-set link with no href — adoption matches on a null + // href — and the wire entry drops the key when there is none. + // + // Canonical qualifier matching (2026-09-01): 11.37 -> 11.38 KB, measured + // at 11.374 (+14 B). The frame client's mirrored `qualifierValue` folds + // `as` and reads an empty source set or size as absent, so a document + // link spelled `as="IMAGE"` or carrying `imagesrcset=""` adopts instead + // of duplicating — the same rules head.ts applies. + // + // Rebased onto next after the patch-channel removal (2026-09-02): + // 11.30 -> 11.40 KB, measured at 11.372 against next's 11.266 — +106 B + // for the whole branch (identity canonicalization, the source-set form, + // and the mirrored qualifier folding above). The per-commit notes were + // measured on the pre-removal base, so their absolutes no longer line + // up with this file's floor, but their deltas do. path: "../../packages/web/frames/dist/client.js", - limit: "11.30 KB", + limit: "11.40 KB", modifyEsbuildConfig: framesEsbuildConfig } ];