Skip to content

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

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

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

Conversation

@v-sayapin

@v-sayapin v-sayapin commented Sep 1, 2026

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

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:

  • false was a value, not absence. Both attribute writers drop it, so crossorigin={cond && "anonymous"} emitted a second, byte-identical link.
  • 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, every other present value is Anonymous. Eight registrations of one font across five Anonymous spellings gave six links; now three.
  • Fields could forge each other. type: "a:media=b" collided with type: "a", media: "b", and /loader:type=6:module read as /loader plus type: "module". Every free-form field is length-prefixed now, the URL included.

One shared qualifierValue owns the rules and applies exactly what registration applies — as folds ASCII-case, an empty or non-string imagesrcset/imagesizes reads as absent — otherwise a client as="IMAGE" or imagesrcset: "" mounted a second link beside the server's. Identity, the document client and the frame client's mirror all route through it. So mountHeadResource stops 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 to 100vw, 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.avif is one URL and https://cdn.example/image,400w 1x is 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 via sink.asset). Documented at the handoff site rather than changed while that API is experimental.

The leading commit is unrelated: it restores the AssetManifest note and frames budget rationale from #3162 (df453d7, 47b1949), lost to a force-push before the merge. Rebased on next past 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 crossorigin spellings 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#test on three element.spec.tsx cases (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 on next at ff96a671, which CI has green. packages/web otherwise: 1578 passed / 2 skipped across its three configs.
  • size-limit — 8/8. Frames client 11.30 → 11.40 kB: 11372 B against 11266 B on next, +106 B for the whole branch. Simple-app floor 10.73 → 10.74: 10731 B against 10700 B with only dist/web.js swapped while the tree-shaken bundle is byte-identical — two head.ts helpers 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.
  • Prettier, test-types, typecheck and git diff --check clean. CodSpeed's merge swings are in untouched packages/signals with a runtime-environment mismatch the report itself flags; acknowledged as noise.
  • 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.

@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9dd8d0f

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 not alter performance

✅ 136 untouched benchmarks


Comparing v-sayapin:feat/responsive-preload-links (9dd8d0f) with next (ff96a67)

Open in CodSpeed

@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.

@v-sayapin
v-sayapin force-pushed the feat/responsive-preload-links branch from a33fcf0 to 9dd8d0f Compare September 3, 2026 16:44
@v-sayapin

Copy link
Copy Markdown
Author

Done!
All three fixed in the last commit.
The branch is rebased on next.
Stale @dom-expressions/runtime note dropped.

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