feat(web): support responsive image preloads - #3183
Conversation
🦋 Changeset detectedLatest commit: 9dd8d0f The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
ryansolid
left a comment
There was a problem hiding this comment.
The overall responsive-preload shape is sound, the focused tests pass against current next, and the reported CodSpeed signals swings are environment noise. I found three correctness gaps to address before merge:
-
Resource identities are still forgeable at the URL boundary (
packages/web/src/head.ts,resourceIdentity). Qualifier values are now length-prefixed, but the leading URL is not. For example,/loader:type=6:modulewith notypecollides with/loaderplustype=\"module\", suppressing a distinct valid resource. Please encode every identity field unambiguously, including the URL, and add a collision regression test. -
Registration and hydration adoption canonicalize qualifiers differently. Registration lowercases
asand drops empty/non-string responsive attributes, whilefindAssetElement/ the frame client'sfindHeadElementcompare rawas,imagesrcset, andimagesizes. Standards-equivalent input such asas=\"IMAGE\"vsas=\"image\", or omitted vs filtered-empty responsive values, can fail adoption and duplicate an SSR link. Please route server identity, document-client adoption, and frame-client adoption through the same canonicalization rules and pin both cases. -
hasWidthDescriptoris not srcset-aware. Scanning the raw string misreads a valid density candidate such ashttps://cdn.example/image,400w 1xas containing a width descriptor and emits a false missing-imagesizeswarning. Reuse the candidate/descriptor parser rather than searching the whole string.
The note about needing a matching @dom-expressions/runtime change appears stale and is not a blocker: that runtime was absorbed here, and the corresponding dom-expressions PR was closed in favor of the earlier Solid work. The size increases are small and justified once the correctness gaps are closed.
a33fcf0 to
9dd8d0f
Compare
|
Done! |
Summary
Follow-up to #3162, which landed typed preload links but left responsive images out.
imagesrcsetandimagesizesdecide which URL the browser fetches, so a typed link that can't carry them only half-describes the request.<link rel="preload" as="image">now carries both, including the standard form with nohref:Dropping
hrefis the spec's own advice for the source-set form — it only serves browsers withoutimagesrcsetsupport, and there it tends to preload the wrong candidate. React does the same for the same reason.PreloadLinkbecomes a union so the type states the three legal shapes instead of leaving them to runtime checks, andasstays the spec's set of preload destinations exactly, since anything else translates to null and the browser ignores the link.Candidate URLs have to arrive resolved.
_basestill joinshref, but the source set rides verbatim — rewriting each candidate would put a srcset parser on the render path for something the producer already knows.Bad responsive attributes don't take the link down with them. On a non-image destination the attribute is filtered and the preload still ships, so an integration computing
imagesrcsetfor every asset keeps its script and style links.""and non-string values count as absent too — coercing them would emitimagesrcset="42", which no browser can parse, and then build an identity out of it.That makes validation order load-bearing. The destination decides whether a source set is a source, so
asis resolved and the pair normalized before the "has a source" check. The other way round acceptedimagesrcseton a non-image destination as the source, then filtered that same attribute away, leaving<link rel="preload" as="script">with nothing to fetch.The identity fix underneath
Its own commit: a bug in what #3162 landed, not part of this feature. Resource identity compared raw prop values, and disagreed with its own markup three ways:
falsewas a value, not absence. Both attribute writers drop it, socrossorigin={cond && "anonymous"}emitted a second, byte-identical link.crossoriginwas compared by spelling. It's a CORS settings attribute — three states, not a string range: absent is No CORS,use-credentialsis Use Credentials, every other present value is Anonymous. Eight registrations of one font across five Anonymous spellings gave six links; now three.type: "a:media=b"collided withtype: "a", media: "b", and/loader:type=6:moduleread as/loaderplustype: "module". Every free-form field is length-prefixed now, the URL included.One shared
qualifierValueowns the rules and applies exactly what registration applies —asfolds ASCII-case, an empty or non-stringimagesrcset/imagesizesreads as absent — otherwise a clientas="IMAGE"orimagesrcset: ""mounted a second link beside the server's. Identity, the document client and the frame client's mirror all route through it. SomountHeadResourcestops adopting across a different destination or CORS mode, and a source-set link becomes adoptable at all: no href, so it matches a null one plus the qualifiers. Before that a responsive preload duplicated on hydration.Dev warnings
Two conformance gaps now report instead of shipping quietly: a width descriptor with no
imagesizes(the source size falls back to100vw, so the preload can miss the candidate the<img>picks), and a relative candidate in a manifest source set — not gated on_base, since it resolves against the document URL either way. Both walk the source set the way the spec's parser does, URL and descriptors apart, so/w,400/hero.avifis one URL andhttps://cdn.example/image,400w 1xis a density candidate, not a width descriptor.Left alone
Head order is unchanged — moving an image preload ahead of render-blocking CSS could lengthen the critical path, and Chrome takes image priority from
fetchpriority, not position. Same for a preload budget: Angular can warn past a limit because it generates those links, whereas this pipeline is handed explicit descriptors and can't know which resource is the LCP element. That belongs in an integration.sink.shell's meta containers are live rather than snapshots (a late registration pushes into them and arrives viasink.asset). Documented at the handoff site rather than changed while that API is experimental.The leading commit is unrelated: it restores the
AssetManifestnote and frames budget rationale from #3162 (df453d7, 47b1949), lost to a force-push before the merge. Rebased onnextpast the patch-channel removal (#3229).How did you test this change?
Extended the existing suites rather than adding parallel ones. New cases cover both responsive shapes on every emission path; the pair filtered on a non-image destination and kept on an image; five Anonymous
crossoriginspellings collapsing to one link; a delimiter-carrying value and URL not suppressing another resource;as="IMAGE"and filtered-empty responsive values adopting the server's link on the identity, the document client and the frame client; both warnings firing once and staying quiet for density-only art direction and commas inside a candidate URL.pnpm test— 32/33 workspace tasks. The one failure is@solidjs/web#teston threeelement.spec.tsxcases (Solid 2 RC.4: should static and reactive<select value>behave differently? #3167); those and five hydrate-config cases ([Solid 2 regression] Direct value and checked bindings overwrite pre-hydration user input #3182, ref-spread parity) fail identically onnextatff96a671, which CI has green.packages/webotherwise: 1578 passed / 2 skipped across its three configs.size-limit— 8/8. Frames client11.30 → 11.40 kB:11372 Bagainst11266 Bonnext, +106 B for the whole branch. Simple-app floor10.73 → 10.74:10731 Bagainst10700 Bwith onlydist/web.jsswapped while the tree-shaken bundle is byte-identical — twohead.tshelpers this bundle never reaches shift esbuild's identifier allocation and brotli pays 31 B for the names. Layout drift, ratcheted per the file's rule; rationale in.size-limit.js.test-types,typecheckandgit diff --checkclean. CodSpeed'smergeswings are in untouchedpackages/signalswith a runtime-environment mismatch the report itself flags; acknowledged as noise.dist: the source-set form selects the right candidate and the matching<img srcset sizes>makes no second request; twomedia-gated art-direction links fetch only the matching one; a sourceless descriptor emits nothing and a coerced one emits no garbage attribute; eight font registrations produce two links in the live DOM; andas="script"/as="style"preloads are CSP-blocked without their nonce and served with it.