Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/preload-identity-qualifiers.md
Original file line number Diff line number Diff line change
@@ -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 `<link>` 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.
11 changes: 11 additions & 0 deletions .changeset/responsive-preload-links.md
Original file line number Diff line number Diff line change
@@ -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 `<link rel="preload">` 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 `<img>` 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.
28 changes: 24 additions & 4 deletions packages/web/frames/src/frame-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export type FrameChunk =
modules?: string[];
styles?: string[];
inlineStyles?: { id: string; content?: string; attrs?: Record<string, string> }[];
preloads?: { href: string; attrs: Record<string, string> }[];
preloads?: { href?: string; attrs: Record<string, string> }[];
}
| { type: "slot"; id: string; version: number; key: string; args: Record<string, unknown> }
| { type: "complete"; id: string; version: number }
Expand Down Expand Up @@ -2061,14 +2061,33 @@ 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)) {
if (node.getAttribute(attr) !== value) continue;
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;
}
Expand All @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/web/frames/src/frame-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

/**
Expand Down
36 changes: 29 additions & 7 deletions packages/web/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ import {
resourceIdentity,
replaceableIdentity,
resolveHead,
RESOURCE_QUALIFIERS,
qualifierValue,
STYLESHEET_FETCH_META
} from "./head.js";
export {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down
62 changes: 57 additions & 5 deletions packages/web/src/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
50 changes: 45 additions & 5 deletions packages/web/src/server-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ function throwInBrowser(func: Function) {
console.error(err);
}

/** An explicit `<link rel="preload">` emitted by the SSR asset pipeline. */
export type PreloadLink = {
href: string;
as: JSX.HTMLPreloadAs;
type PreloadLinkAttributes = {
type?: string;
crossorigin?: JSX.HTMLCrossorigin;
integrity?: string;
Expand All @@ -20,7 +17,50 @@ export type PreloadLink = {
media?: string;
};

/** Static asset manifest produced by a build (e.g. parsed Vite manifest.json). */
/**
* An explicit `<link rel="preload">` 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 `<img>`
* 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<JSX.HTMLPreloadAs, "image">;
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,
{
Expand Down
Loading
Loading