Improve the front-end DX of <img srcset>

Created on 27 June 2025, 28 days ago

Overview

Currently, the image prop type consists of 4 properties: src, alt, width, and height.

This means a (Twig) SDC can render an image prop like this:

<img src="{{ image.src }}" alt="{{ image.alt }}" width="{{ image.width }}" height="{{ image.height }}" />

And a (JS) Code Component can render it like this:

export default function({ image }) {
  return (
    <img src={ image.src } alt={ image.alt } width={ image.width } height={ image.height } />
  )
}

Or, taking advantage of JSX prop spreading, like this:

export default function({ image }) {
  const { src, alt, width, height } = image;
  return (
    <img { ...{src, alt, width, height} } />
  )
}

In โœจ Add automated image optimization to image component Active , we're working on adding a 5th property: srcsetCandidateTemplate. That name is still a work in progress. For the purpose of this issue, let's simplify it to scaledSrc. The idea is that src would be the original image, or possibly one with an image style applied for visual effect (color shifting, watermarking, cropping, etc.), but not for size optimization. Meanwhile, scaledSrc would be the URL template for scaling to one of several widths. For example:

src = '/sites/default/files/foo.jpg'
scaledSrc = '/sites/default/files/styles/xb_parameterized_width--{width}/public/foo.jpg.webp?itok=1Rl59WAb'

Assuming we add a toSrcSet filter, this would allow a Twig SDC to do this:

<img src="{{ image.src }}" alt="{{ image.alt }}" width="{{ image.width }}" height="{{ image.height }}" srcset="{{ image.scaledSrc|toSrcSet }}" sizes="auto 100vw" />

It would allow a JS code component to do something similar, but where JS components really shine is in the ability to use the JS ecosystem, such as next/image (or a version of it that can be used on its own without Next.js: next-image-standalone). So, for example:

import Image from "next-image-standalone";

export default function({ image }) {
  const { src, alt, width, height, scaledSrc } = image;
  const loader = ({ width }) => scaledSrc.replace('{width}', width);

  return (
    <Image { ...{src, alt, width, height, loader} } />
  )
}

The above is nice and enables the code component to use various other nice features from next/image by passing the appropriate props. But one thing that's annoying about the above is the need to pass in an explicit loader prop. Although it's possible to configure a centralized loader, so long as it's relying on scaledSrc, the code component has to pass along scaledSrc somehow, just like the Twig version earlier.

I discussed this with @lauriii and he's been pushing back on this, asking: is it possible to let component authors just deal with standard <img> concepts like src, alt, width, and height, and not introduce any Drupalisms like a scaledSrc property?

Proposed resolution

What if instead of separate src and scaledSrc properties we combined both into src like this:

src = '/sites/default/files/foo.jpg?alternateWidths=%2Fsites%2Fdefault%2Ffiles%2Fstyles%2Fxb_parameterized_width--%7Bwidth%7D%2Fpublic%2Ffoo.jpg.webp%3Fitok%3D1Rl59WAb'

In other words, instead of a scaledSrc property we add it as a query parameter to src. This query parameter wouldn't affect the response if the browser requests this src (especially if foo.jpg is already on disk in which case query parameters do nothing), but it would allow the Twig SDC to do this:

<img src="{{ image.src }}" alt="{{ image.alt }}" width="{{ image.width }}" height="{{ image.height }}" srcset="{{ image.src|toSrcSet }}" sizes="auto 100vw" />

And more importantly, it would allow a JS code component to do this:

import Image from "next-image-standalone";

export default function({ image }) {
  const { src, alt, width, height } = image;

  return (
    <Image { ...{src, alt, width, height} } />
  )
}

Where a default loader function could be configured completely invisibly and from the perspective of the component author, they're using next/image completely idiomatically.

๐Ÿ“Œ Task
Status

Active

Version

0.0

Component

Shape matching

Created by

๐Ÿ‡บ๐Ÿ‡ธUnited States effulgentsia

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Merge Requests

Comments & Activities

  • Issue created by @effulgentsia
  • ๐Ÿ‡ซ๐Ÿ‡ฎFinland lauriii Finland

    ๐Ÿ’ฏ ๐Ÿคฉ ๐Ÿ‘

  • ๐Ÿ‡ณ๐Ÿ‡ฑNetherlands balintbrews Amsterdam, NL

    Wow. What an amazing idea! ๐Ÿ™‡

  • ๐Ÿ‡บ๐Ÿ‡ธUnited States effulgentsia

    Tagging this as beta target, because it would be nice to not have to either break BC after beta1 or support BC related to this. But not tagging it as a beta blocker, because it's not worth delaying a beta release on it.

  • ๐Ÿ‡ฎ๐Ÿ‡ณIndia libbna New Delhi, India

    I have given this issue a try: I checked the MR of #3515646 โœจ Image component for code components Active & then I have updated the Image component to implement the proposed solution that eliminates the need for Drupal-specific props while maintaining full responsive image functionality.

    import NextImage from 'next-image-standalone';
    import type { ImageLoaderParams, ImageProps } from 'next-image-standalone';
    
    const parseAlternateWidths = (src: string): string | null => {
      try {
        const url = new URL(src, window.location.origin);
        const alternateWidths = url.searchParams.get('alternateWidths');
        return alternateWidths ? decodeURIComponent(alternateWidths) : null;
      } catch {
        return null;
      }
    };
    
    export default function Image({
      src,
      alt,
      width,
      height,
      ...props
    }: Omit<ImageProps, 'loader'>) {
      const loader = ({ width }: ImageLoaderParams) => {
        const alternateWidths = parseAlternateWidths(src);
        if (alternateWidths) {
          return alternateWidths.replace('{width}', width.toString());
        }
    
        return src;
      };
    
      return (
        <NextImage
          src={src}
          alt={alt}
          width={width}
          height={height}
          loader={loader}
          {...props}
        />
      );
    }
    
    1. Removed srcSetCandidateTemplate prop
    2. Added URL parameter parsing via parseAlternateWidths
    3. Combined image source and responsive width info into single src prop
    4. Simplified component API for better DX (Developer Experience)

    Before pushing the changes, Iโ€™d appreciate it if someone could review the approach and let me know if any improvements or adjustments are needed. Thanks!

  • First commit to issue fork.
  • Pipeline finished with Failed
    17 days ago
    Total: 778s
    #541737
  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ

    Initial review posted.

  • ๐Ÿ‡ช๐Ÿ‡ธSpain isholgueras

    @effulgentsia,

    I'm missing something in your approach that is making me going back and forth in the ticket. I've pushed some code to help me explain that.

    What you're proposing is to modify what is coming in the img.src with the url to the image itself, without any style, and as a query parameters alternateWidths send only the template (with the {width} and the itok) so the ui can react and modify.

    The decoded url you've written is

        // /sites/default/files/foo.jpg?alternateWidths=/sites/default/files/styles/xb_parameterized_width--{width}/public/foo.jpg.webp?itok=1Rl59WAb
    

    Is that exactly that we want to send in the src=""

    And for srcset what we want to generate is a full list of allowed widths? Something like:

    <img class="image" src="/sites/default/files/2025-07/2025-07-02_17-30.png" srcset="/sites/default/files/styles/xb_parameterized_width--640/public/2025-07/3534165-6_2.png 640w, /sites/default/files/styles/xb_parameterized_width--750/public/2025-07/3534165-6_2.png 750w, /sites/default/files/styles/xb_parameterized_width--828/public/2025-07/3534165-6_2.png 828w, /sites/default/files/styles/xb_parameterized_width--1080/public/2025-07/3534165-6_2.png 1080w, /sites/default/files/styles/xb_parameterized_width--1200/public/2025-07/3534165-6_2.png 1200w, /sites/default/files/styles/xb_parameterized_width--1920/public/2025-07/3534165-6_2.png 1920w, /sites/default/files/styles/xb_parameterized_width--2048/public/2025-07/3534165-6_2.png 2048w, /sites/default/files/styles/xb_parameterized_width--3840/public/2025-07/3534165-6_2.png 3840w" alt="Phandalin" width="1487" height="1003" sizes="auto 100vw" loading="lazy">
    

    I still don't get what is the HTML we want to return for this, sorry :(

  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ

    Is that exactly that we want to send in the src=""

    No, we'd want to strip ?alternateWidths=โ€ฆ

    And for srcset what we want to generate is a full list of allowed widths?

    Yes. Render the sdc.experience_builder.image SDC in HEAD and copy/paste the resulting markup. That is the end result you must achieve using these different mechanics ๐Ÿ˜Š

  • ๐Ÿ‡บ๐Ÿ‡ธUnited States effulgentsia

    No, we'd want to strip ?alternateWidths=โ€ฆ

    We can strip this from what the Twig renders into the src attribute, but we don't have to. I think it's okay for the Twig to just do src="{{ image.src }}" as the MR already does and for that to mean the query parameter is retained. The query parameter is harmless other than adding some extra bytes to the HTML output, and those extra bytes are minor compared to what we end up needing in srcset anyway.

    This MR still needs, within ShapeMatchingHooks.php, where we set the expression for srcSetCandidateTemplate to instead change the expression of src to be such that it evaluates to a URL that includes the alternateWidths query parameter. Eventually stuff like this could be done with AdaptedPropSource but I don't know if we have adapters working sufficiently well yet, so it might make sense to instead add another computed property to ImageItemOverride. XB's shape matching and prop expressions are kind of a tricky part of XB, so perhaps @wim leers could help out with this part?

  • ๐Ÿ‡ช๐Ÿ‡ธSpain isholgueras

    Thanks both for your feedback! I know exactly what needs to be done.

  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ

    I think it's okay for the Twig to just do src="{{ image.src }}" as the MR already does and for that to mean the query parameter is retained.

    That's technically fine indeed ๐Ÿ‘

    This MR still needs, within ShapeMatchingHooks.php, where we set the expression for srcSetCandidateTemplate to instead change the expression of src to be such that it evaluates to a URL that includes the alternateWidths query parameter.

    Close! Making src be different will require changing:

    • \Drupal\experience_builder\Plugin\Field\FieldTypeOverride\ImageItemOverride::propertyDefinitions()
    • \Drupal\experience_builder\TypedData\ImageDerivativeWithParametrizedWidth::getValue()
    • \Drupal\experience_builder\Entity\ParametrizedImageStyle::buildUrlTemplate()

    Eventually stuff like this could be done with AdaptedPropSource but I don't know if we have adapters working sufficiently well yet

    They do on the back end at a low level; test coverage proves they work fine.

    But โ€ฆ neither the front-end (XB UI) nor the back-end's \Drupal\experience_builder\Form\ComponentInputsForm that powers the tab support it today. Because it's blocked on design.

    , so it might make sense to instead add another computed property to ImageItemOverride. XB's shape matching and prop expressions are kind of a tricky part of XB, so perhaps @wim leers could help out with this part?

    Yep, my thoughts exactly!

    On it ๐Ÿ‘

  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ

    Extra complexity here is that we only know at the ImageItem level what the special sauce is, but the src (image URL) is currently coming from

                'src' => new ReferenceFieldTypePropExpression(
                  new FieldTypePropExpression('image', 'entity'),
                  new FieldPropExpression(BetterEntityDataDefinition::create('file'), 'uri', NULL, 'url'),
                ),
    

    ๐Ÿ‘†That follows the image field type's entity property, and on the File entity that that points to, it instructs XB to retrieve the uri field's url property. String representation: srcโ†entityโœโœentity:fileโuriโžโŸurl.

    We'd now need to change that quite a bit, and crucially, in a way that the dependency information remains present. ๐Ÿ˜…

  • Pipeline finished with Failed
    16 days ago
    Total: 941s
    #542788
  • Pipeline finished with Failed
    16 days ago
    Total: 654s
    #542800
  • Pipeline finished with Failed
    16 days ago
    Total: 687s
    #542810
  • Pipeline finished with Failed
    16 days ago
    Total: 761s
    #542824
  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ
    Drupal\Tests\experience_builder\Kernel\DataType\ComponentInputsDependenciesTest::testCalculateDependencies
    Failed asserting that two arrays are identical.
    --- Expected
    +++ Actual
    @@ @@
             5 => 'file',
             6 => 'node',
             7 => 'file',
    -        8 => 'node',
    -        9 => 'file',
         ],
         'config' => Array &2 [
             0 => 'node.type.alpha',
    @@ @@
             9 => 'node.type.alpha',
             10 => 'field.field.node.alpha.field_hero',
             11 => 'image.style.xb_parametrized_width',
    -        12 => 'node.type.alpha',
    -        13 => 'field.field.node.alpha.field_hero',
    -        14 => 'image.style.xb_parametrized_width',
    -    ],
    -    'content' => Array &3 [
    -        0 => 'file:file:d500fabe-6e85-4877-b250-a6d719fdb53e',
         ],
     ]
    

    โ€” results for ComponentInputsDependenciesTest

    This is what I predicted in #14 about dependency information going missing: it's because we're no longer having XB itself follow the image field โ†’ entity reference โ†’ file entity โ†’ uri field โ†’ url property chain, hence XB doesn't know about this dependency: it's all abstracted away by this new computed field property.

    So that computed field property must somehow provide the correct dependency informationโ€ฆ tricky!

  • Pipeline finished with Failed
    16 days ago
    Total: 1101s
    #542849
  • Pipeline finished with Failed
    15 days ago
    Total: 1048s
    #542911
  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ

    Got an initial solution for the dependency challenge above partially working. It needs more attention.

    But due to the reliance on this computed property, we introduced a new challenge: the automatic (typed data/constraint-based) shape for finding candidate DynamicPropSources now won't work anymore: it continues to find what's in HEAD (follow the entity reference down to the file). So that logic will now also need to be updated for all tests to pass. If @isholgueras can get it to the point where that is the cause for the last remaining failures, that'd be great!

  • Pipeline finished with Failed
    15 days ago
    Total: 1182s
    #542956
  • Pipeline finished with Failed
    15 days ago
    Total: 722s
    #542980
  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ
  • Pipeline finished with Failed
    15 days ago
    Total: 979s
    #543007
  • Pipeline finished with Failed
    15 days ago
    Total: 828s
    #543171
  • Pipeline finished with Failed
    14 days ago
    Total: 474s
    #543912
  • Pipeline finished with Running
    14 days ago
    #544029
  • Pipeline finished with Canceled
    14 days ago
    Total: 120s
    #544026
  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ
  • Pipeline finished with Failed
    14 days ago
    Total: 678s
    #544109
  • Pipeline finished with Failed
    14 days ago
    Total: 1000s
    #544241
  • ๐Ÿ‡ณ๐Ÿ‡ฑNetherlands balintbrews Amsterdam, NL

    I added all frontend pieces to make image props work in code components (1ec1c952).

    You can test with the following code component:

    import Image from "next-image-standalone";
    
    // Make sure you have an image prop named "Photo".
    const Cover = ({ photo }) => {
      return <Image {...photo} />;
    };
    
    export default Cover;
    
  • Pipeline finished with Failed
    14 days ago
    Total: 946s
    #544261
  • Pipeline finished with Failed
    13 days ago
    Total: 812s
    #545223
  • Pipeline finished with Failed
    13 days ago
    Total: 891s
    #545266
  • Pipeline finished with Failed
    13 days ago
    Total: 3763s
    #545283
  • First commit to issue fork.
  • Pipeline finished with Failed
    13 days ago
    Total: 783s
    #545401
  • ๐Ÿ‡บ๐Ÿ‡ธUnited States effulgentsia

    @lauriii and I discussed that getting the image component DX right should actually block the beta, so I tagged ๐Ÿ“Œ Create an Image SDC that can be included by other SDCs Active as such and promoting this one as well.

  • Pipeline finished with Failed
    11 days ago
    Total: 837s
    #546706
  • Pipeline finished with Failed
    11 days ago
    Total: 812s
    #546745
  • Pipeline finished with Failed
    10 days ago
    #547728
  • Pipeline finished with Failed
    10 days ago
    #547751
  • Pipeline finished with Failed
    10 days ago
    #547789
  • Pipeline finished with Failed
    10 days ago
    #547791
  • Pipeline finished with Failed
    10 days ago
    #547802
  • Pipeline finished with Failed
    10 days ago
    #547814
  • Pipeline finished with Failed
    10 days ago
    #547820
  • Pipeline finished with Failed
    9 days ago
    #547829
  • Pipeline finished with Failed
    9 days ago
    #547846
  • Pipeline finished with Failed
    9 days ago
    #547861
  • Pipeline finished with Failed
    9 days ago
    #547875
  • Pipeline finished with Failed
    9 days ago
    #547914
  • Pipeline finished with Failed
    9 days ago
    Total: 675s
    #548067
  • Pipeline finished with Failed
    9 days ago
    Total: 611s
    #548075
  • Pipeline finished with Failed
    9 days ago
    Total: 887s
    #548089
  • Pipeline finished with Failed
    9 days ago
    Total: 1752s
    #548131
  • Pipeline finished with Failed
    9 days ago
    Total: 800s
    #548168
  • Pipeline finished with Canceled
    9 days ago
    Total: 123s
    #548191
  • Pipeline finished with Failed
    9 days ago
    Total: 770s
    #548192
  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ

    Got the shape matching pieces done.

    With 0.6.0-alpha1 out and working on this, my mind had the space to spot a whole range of concerns. Created ๐Ÿ“Œ Allow use of same-shape-adapters ahead of general adapter support in #3464003 Active for those. This issue doesn't make things worse (it follows an already established pattern), so we shouldn't block this on that.

    2 concerns about the public API of the new Twig function remain, @isholgueras is solving those,:

    1. https://git.drupalcode.org/project/experience_builder/-/merge_requests/1...
    2. https://git.drupalcode.org/project/experience_builder/-/merge_requests/1...

    Once those are solved, I think this is ready to land! ๐Ÿš€

  • Pipeline finished with Failed
    9 days ago
    Total: 825s
    #548218
  • ๐Ÿ‡ณ๐Ÿ‡ฑNetherlands balintbrews Amsterdam, NL

    I made a small change in the starter code so we don't set a bad example with prop spreading. Thanks to @effulgentsia for pointing that out. I re-tested the code component functionality, still works great with the changes by this MR. The UI code probably could use a quick review as I wrote all of it, I wouldn't want to sign off on my own code. ๐Ÿ™‚

  • Pipeline finished with Failed
    9 days ago
    Total: 1008s
    #548315
  • Pipeline finished with Failed
    9 days ago
    Total: 695s
    #548373
  • Pipeline finished with Failed
    9 days ago
    Total: 912s
    #548385
  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ

    Pushed commits adding @todo comments pointing to โœจ [later phase] PropShapes' JSON schema must match exactly with FieldType's storage format โ€” what if you want to use another FieldType? Active , ๐Ÿ“Œ Decouple image (URI) shape matching from specific image file types/extensions Active and ๐Ÿ“Œ Allow use of same-shape-adapters ahead of general adapter support in #3464003 Active .

    Addressed the last remaining concern.

    Did a final clean-up pass to and in doing so, found an edge case bug: if the image itself was exactly the largest allowed parametrized image style width, we'd generate a srcset width for it, which makes no sense: it'd mean generating a derivative of identical dimensions as the original โ€” costing unnecessary CPU + storage resources to the server, and unnecessary network transfer for both client and server.

    Thanks @hooroomoo for the front-end approval!

    Let's ship this ๐Ÿ˜Š โ€” and I'll see some of you in ๐Ÿ“Œ Allow use of same-shape-adapters ahead of general adapter support in #3464003 Active tomorrow ๐Ÿค“

  • Pipeline finished with Failed
    9 days ago
    Total: 1645s
    #548397
  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ

    The only failure: playwright, but that's a known pseudo-random fail: ๐Ÿ› Some playwright jobs are failing with test:prettier error. Active .

  • ๐Ÿ‡ง๐Ÿ‡ชBelgium wim leers Ghent ๐Ÿ‡ง๐Ÿ‡ช๐Ÿ‡ช๐Ÿ‡บ

    Thanks!

Production build 0.71.5 2024