feat(web): support responsive image preloads - #3183
Conversation
🦋 Changeset detectedLatest commit: a33fcf0 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 |
Merging this PR will degrade performance by 14.3%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | merge |
70.9 µs | 138.2 µs | -48.71% |
| ⚡ | merge |
322.7 µs | 225.3 µs | +43.21% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing v-sayapin:feat/responsive-preload-links (a33fcf0) with next (f051db6)
Footnotes
-
132 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
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.
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
Separate first commit, because it's a bug in what #3162 landed rather than 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. The client already treatedfalseas absent, so the two sides disagreed about what a resource is.crossoriginwas compared by spelling. It's a CORS settings attribute — three states, not a string range. Absent is No CORS,use-credentialsis Use Credentials, and every other present value ("", a bare attribute, an invalid one) is Anonymous. Eight registrations of one font across five Anonymous spellings gave six links; now three.:q=valueconcatenation meanttype: "a:media=b"collided withtype: "a", media: "b"and silently dropped the second resource. Values are length-prefixed now.One shared
qualifierValueowns all three, and the identity, the document client'sfindAssetElementand the frame client'sfindHeadElementroute through it. SomountHeadResourcealso stops adopting across a different destination or CORS mode, and a source-set link becomes adoptable at all — it has no href, so it matches a null one plus the qualifiers. Without 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 — no longer gated on_base, since a relative candidate resolves against the document URL either way. That check walks the source set the way the spec's parser does, so/w,400/hero.avifreads as one URL rather than two relative candidates.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. No trace here says otherwise. 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 first commit is unrelated to the feature: it restores the
AssetManifestnote and frames budget rationale from #3162 (df453d7, 47b1949), lost to a force-push before the merge. Needs the matching@dom-expressions/runtimechange to land alongside — the two runtimes must not disagree about what a resource is.How did you test this change?
Extended the existing suites rather than adding parallel ones. New cases cover both responsive shapes through the manifest,
registerAsset, streaming, custom sinks and the frame wire; the source-set-only descriptor dropped on a non-image destination and kept on an image; non-string values filtered rather than coerced; all five Anonymouscrossoriginspellings collapsing to one link anduse-credentialsto another; a falsy conditional qualifier not forking the identity and adopting the server's link; a delimiter-carrying value not suppressing another resource; href-less and href-bearing links not adopting each other in both clients; both new warnings firing exactly once, and staying quiet for density-only art direction and for commas inside a candidate URL.pnpm test— 33/33 workspace tasks.packages/webis 1397 passed / 2 skipped across its three configs.size-limit— 10/10. Frames client11.28 -> 11.34 -> 11.37 kB(identity commit, then the feature): it mirrorshead.tsinstead of importing it, so it's the only bundle paying for the canonical rules — the five document scenarios came out byte-identical there. The feature itself moveshydrating-appto17.59 kB(17.570 measured) andcsr-app23 B down on brotli layout. Every bump carries its rationale in.size-limit.js, and the intermediate commit passes on its own.test-types,typecheckandgit diff --checkclean.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, so the split-nonce routing is load-bearing rather than decorative.