Skip to content

feat(web): support responsive image preloads - #3183

Open
v-sayapin wants to merge 3 commits into
solidjs:nextfrom
v-sayapin:feat/responsive-preload-links
Open

feat(web): support responsive image preloads#3183
v-sayapin wants to merge 3 commits into
solidjs:nextfrom
v-sayapin:feat/responsive-preload-links

Conversation

@v-sayapin

Copy link
Copy Markdown

Summary

Follow-up to #3162, which landed typed preload links but left responsive images out. imagesrcset and imagesizes decide 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 no href:

preloads: [
  { href: "/hero.avif", as: "image", imagesrcset: "/hero.avif 1x, /hero@2x.avif 2x", imagesizes: "50vw" },
  { as: "image", imagesrcset: "/card-400.avif 400w, /card-800.avif 800w", imagesizes: "100vw" }
]

Dropping href is the spec's own advice for the source-set form — it only serves browsers without imagesrcset support, and there it tends to preload the wrong candidate. React does the same for the same reason. PreloadLink becomes a union so the type states the three legal shapes instead of leaving them to runtime checks, and as stays 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. _base still joins href, 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 imagesrcset for every asset keeps its script and style links. "" and non-string values count as absent too — coercing them would emit imagesrcset="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 as is resolved and the pair normalized before the "has a source" check. The other way round accepted imagesrcset on 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:

  • false was a value, not absence. Both attribute writers drop it, so crossorigin={cond && "anonymous"} emitted a second, byte-identical link. The client already treated false as absent, so the two sides disagreed about what a resource is.
  • crossorigin was compared by spelling. It's a CORS settings attribute — three states, not a string range. Absent is No CORS, use-credentials is 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.
  • Values could forge each other. :q=value concatenation meant type: "a:media=b" collided with type: "a", media: "b" and silently dropped the second resource. Values are length-prefixed now.

One shared qualifierValue owns all three, and the identity, the document client's findAssetElement and the frame client's findHeadElement route through it. So mountHeadResource also 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 to 100vw, 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.avif reads 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 via sink.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 AssetManifest note and frames budget rationale from #3162 (df453d7, 47b1949), lost to a force-push before the merge. Needs the matching @dom-expressions/runtime change 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 Anonymous crossorigin spellings collapsing to one link and use-credentials to 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/web is 1397 passed / 2 skipped across its three configs.
  • size-limit — 10/10. Frames client 11.28 -> 11.34 -> 11.37 kB (identity commit, then the feature): it mirrors head.ts instead 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 moves hydrating-app to 17.59 kB (17.570 measured) and csr-app 23 B down on brotli layout. Every bump carries its rationale in .size-limit.js, and the intermediate commit passes on its own.
  • Prettier, test-types, typecheck and git diff --check clean.
  • Checked in Chrome against the built dist: the source-set form selects the right candidate and the matching <img srcset sizes> makes no second request; two media-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; and as="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.

@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a33fcf0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

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

@codspeed-hq

codspeed-hq Bot commented Sep 1, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 14.3%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 134 untouched benchmarks
⏩ 132 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

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)

Open in CodSpeed

Footnotes

  1. 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 ryansolid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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:module with no type collides with /loader plus type=\"module\", suppressing a distinct valid resource. Please encode every identity field unambiguously, including the URL, and add a collision regression test.

  2. Registration and hydration adoption canonicalize qualifiers differently. Registration lowercases as and drops empty/non-string responsive attributes, while findAssetElement / the frame client's findHeadElement compare raw as, imagesrcset, and imagesizes. Standards-equivalent input such as as=\"IMAGE\" vs as=\"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.

  3. hasWidthDescriptor is not srcset-aware. Scanning the raw string misreads a valid density candidate such as https://cdn.example/image,400w 1x as containing a width descriptor and emits a false missing-imagesizes warning. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants