Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[gatsby-image] Propagate imgStyle prop down to non-js images #22607

Closed
wants to merge 12 commits into from
Closed

[gatsby-image] Propagate imgStyle prop down to non-js images #22607

wants to merge 12 commits into from

Conversation

SophieAu
Copy link

Description

This fixes the imgStyle prop not being propagated down to the noscript fallback image.

Documentation

N/A

Related Issues

Fixes #21799

@SophieAu SophieAu requested a review from a team as a code owner March 27, 2020 16:21
Comment on lines 241 to 252
const convertToCSSString = styleObject => {
if (!styleObject) return ""

const cssProps = Object.keys(styleObject)
const jsToCSSProp = capitalLetter => `-${capitalLetter[0].toLowerCase()}`

return cssProps.reduce((cssString, propName) => {
const propAsCSS = propName.replace(/([A-Z])/g, jsToCSSProp)
return cssString + `${propAsCSS}:${styleObject[propName]};`
}, "")
}

Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if there is any react-dom helper function we could use for it? It's for sure needed when we render react elements to html string, so would be nice to reuse it and not have to reimplement it.

Other option would be to actually use React to construct content of <noscript> instead of string concatenation and then we wouldn't need to make conversion like that ourselves. Then we could just set style to be an object 🤔

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor

@vladar vladar Apr 16, 2020

Choose a reason for hiding this comment

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

I don't think those functions are exported from react-dom module. Looks like they are internal?

Copy link
Author

@SophieAu SophieAu Apr 17, 2020

Choose a reason for hiding this comment

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

yeah, I had a look and they don't seem to be exported. I also had a search around the web to see if there was anything else/any libraries that are tested but had no luck.

Unfortunately the noscript tag seems to need a string as child (it calls toString() or similar on its children), so using React doesn't work either. It will just render as [Object object].

Copy link
Author

@SophieAu SophieAu Apr 17, 2020

Choose a reason for hiding this comment

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

Update: I found the ReactDOMServer.renderToString function (https://reactjs.org/docs/react-dom-server.html) that converts a whole React element to a string which seems to work. I'd love some feedback on the approach though.

Note that the current state of the code isn't cleaned up yet since I wanted to get a yes or no on my approach before making everything pretty

Copy link
Contributor

Choose a reason for hiding this comment

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

the ReactDOMServer is problematic, because "server" part of react-dom will need to load in browser - functionally it probably will work, but it will also "bloat" our bundles.

The other approach could be figuring out why we use dangerouslySetInnerHTML in

{this.addNoScript && (
<noscript
dangerouslySetInnerHTML={{
__html: noscriptImg({
alt,
title,
loading,
...image,
imageVariants,
}),
}}
/>
)}

instead of just treating that that regular component - right now we expect string there, and in master we manually construct it and this PR we actually use react to construct markup but there is still weird indirection there. Maybe we don't need to use it? Then we wouldn't need to look into using additional ReactDOMServer indirections?

Copy link
Author

Choose a reason for hiding this comment

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

Unfortunately the noscript tag seems to convert all its children to string (as if it was) using the built-in toString() function, so just passing a component as a child doesn't work since it will just render as [Object object].

From what I could gather this seems to be the (currently) intended API of noscript: facebook/react#15238

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah! Ok, this make sense right now.

So I think the ReactDOMServer is only sensibly route to take, but what we need to make sure of is that we only include this for SSR (so .html is correctly generated) and that for client side it doesn't (as the <noscript> should have no effect in browser context anyway as you do need JS enabled in the first place to use browser bundle).

So curse of action I can see here could be something like that:
Instead of importing react-dom/server directly we have extra module (say render-to-string.js) that looks like this:

if (process.env.BUILD_STAGE === `build-html` || process.env.BUILD_STAGE === `develop-html`) {
  const { renderToString } = require(`react-dom/server`)
  return renderToString
} else {
  module.exports = () => ``
}

This borrows from what we already have in gatsby core runtime ( https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby/cache-dir/public-page-renderer.js )

Unfortunate reality of it is that we need to use CommonJS (require / module.exports) instead of ES modules (import/export), because ES modules can't handle conditional imports/exports. There are likely other ways to do similar things (by using webpack aliases, but gatsby-image is not gatsby plugin, so it doesn't have ability to modify gatsby's webpack configuration.

With setup like that we should be able to avoid adding react-dom/server to browser bundles.

What do you think about this approach?

Also /cc @wardpeet, @blainekasten - if anyone from the team would have objection about this, it might be you ;) so would love to hear your thoughts as well. Both about my proposition and maybe suggestions for alternatives

Copy link
Contributor

@polarathene polarathene Jun 30, 2020

Choose a reason for hiding this comment

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

@pieh How far away is the move to v3?

This would be a stop gap solution to not use the string workaround(that this PR mostly removes via ReactDOMServer) only until then as we're only doing this until minimum React version can be bumped to >=16.6(Discussed in April 2019).

Is this worth going forward with until v3, or could we skip it?

Copy link
Contributor

@pvdz pvdz left a comment

Choose a reason for hiding this comment

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

I have some more superficial, code style related, suggestions.

Comment on lines 210 to 217
const mediaAttr = media ? `media="${media}" ` : ``
const typeAttr = isWebp ? `type='image/webp' ` : ``
const sizesAttr = sizes ? `sizes="${sizes}" ` : ``
const propsObj = {};

return `<source ${typeAttr}${mediaAttr}srcset="${src}" ${sizesAttr}/>`
propsObj["media"] = media;
if(isWebp) propsObj["type"] ='image/webp'
propsObj["srcset"] = isWebp ? srcSetWebp : srcSet
propsObj["sizes"] = sizes

return <source {...propsObj}/>
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a special reason why the property names are dynamic strings rather than regular properties? And since you're creating the object, anyways, you may as well use the shorthand notation.

Also, it looks like prettier/linting wasn't properly applied since this codebase uses backticks for most strings. But linting should would fail in that case anyways so you'd know :)

Suggested change
const mediaAttr = media ? `media="${media}" ` : ``
const typeAttr = isWebp ? `type='image/webp' ` : ``
const sizesAttr = sizes ? `sizes="${sizes}" ` : ``
const propsObj = {};
return `<source ${typeAttr}${mediaAttr}srcset="${src}" ${sizesAttr}/>`
propsObj["media"] = media;
if(isWebp) propsObj["type"] ='image/webp'
propsObj["srcset"] = isWebp ? srcSetWebp : srcSet
propsObj["sizes"] = sizes
return <source {...propsObj}/>
const propsObj = {
media,
type: isWebp ? `image/webp` : undefined,
srcset: isWebp ? srcSetWebp : srcSet,
sizes,
};

Also, if it's relevant whether the type property exists, that would still have to be set conditionally afterwards as JS doesn't have conditional property setting syntax for object literals.

(variant.srcSetWebp ? generateNoscriptSource(variant, true) : ``) +
generateNoscriptSource(variant)
(variant, i) =>
(<React.Fragment key={i}>
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is the key={i} change necessary? Was this to test something? (The word key doesn't appear anywhere else in this PR and I'm not sure if this is just an API thing)

Copy link
Contributor

Choose a reason for hiding this comment

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

When generating React elements through iteration, a key prop should be added as per official docs. It should however be uniquely identifiable by including some data related to the current iteration beyond the index, as that kind of defeats the purpose(docs explain why).

: ``
const loading = props.loading ? `loading="${props.loading}" ` : ``
const draggable = props.draggable ? `draggable="${props.draggable}" ` : ``
const propsObj = {}
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment as above (if you're creating the object then initialize the properties as far as possible, and only use "dynamic property strings" for property names when you have an explicit reason for it)

Copy link
Contributor

Choose a reason for hiding this comment

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

Better still, this should just be created as an object literal with all of the properties, rather than creating an empty object and adding them one by one

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, I totally agree. This is just a quick and dirty POC that I haven't cleaned up yet since we haven't agreed yet if my approach is the right one.

packages/gatsby-image/src/index.js Show resolved Hide resolved
@wardpeet wardpeet self-assigned this May 6, 2020
@SophieAu
Copy link
Author

Note that the code in its current state is just a quick-and-dirty POC while we're still discussing if the approach taken is the right one.

@ascorbic
Copy link
Contributor

I think we've agreed that your approach is sound. I'm happy to merge this with a bit of polish

Comment on lines 244 to 267
const {
src = "",
alt = "",
sizes,
srcSet,
title,
width,
height,
crossOrigin,
loading,
draggable,
} = props

const imageProps = {
src,
alt,
sizes,
srcSet,
title,
width,
height,
crossOrigin,
loading,
draggable,
Copy link
Author

Choose a reason for hiding this comment

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

not sure if this is prettier or this:

Suggested change
const {
src = "",
alt = "",
sizes,
srcSet,
title,
width,
height,
crossOrigin,
loading,
draggable,
} = props
const imageProps = {
src,
alt,
sizes,
srcSet,
title,
width,
height,
crossOrigin,
loading,
draggable,
const imageProps = {
src: props.src || "",
alt: props.alt || "",
sizes: props.sizes,
srcSet: props.srcSet,
title: props.title,
width: props.width,
height: props.height,
crossOrigin: props.crossOrigin,
loading: props.loading,
draggable: props.draggable,

Copy link
Contributor

Choose a reason for hiding this comment

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

It's a bit better by compressing the two, as destructing only to build up an object again throws the reader off a bit, and as this list has been growing, it may just be better diff wise for changes with the compact version you're suggesting here.

An alternative via a utility method would allow for the best of both worlds I think, if we can justify the method with usage elsewhere:

function withoutExcess(src, excess) {
  const trimmed = {};
    
  Object.keys(src)
    .filter(k => !excess[k])
    .forEach(k => { trimmed[k] = src[k]; });
  
  return trimmed;
};

// pluck the props to use
const {
    src,
    alt,
    sizes,
    srcSet,
    title,
    width,
    height,
    crossOrigin,
    loading,
    draggable,
    ...excess
  } = props

// Set defaults in new prop object you'll use them from, then spread the props object with excess props removed
const imageProps = {
    src = ``,
    alt = ``,
    ...withoutExcess(props, excess);
}

I think the method is pretty easy to read and usage is descriptive for the most part? With this approach, we avoid needlessly repeating ourselves, filtering out additional noise.

@danabrit danabrit added the topic: media Related to gatsby-plugin-image, or general image/media processing topics label May 29, 2020
@SophieAu SophieAu requested review from pvdz, ascorbic and pieh June 6, 2020 13:18
@pvdz
Copy link
Contributor

pvdz commented Jun 6, 2020

I ran the formatter over it and lgtm otherwise. Removing myself as reviewer.

@pvdz pvdz removed their request for review June 6, 2020 13:46
Copy link
Contributor

@polarathene polarathene left a comment

Choose a reason for hiding this comment

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

Drop noscript variant methods

generateNoscriptSource() and generateNoscriptSources() are made redundant by this PR. Use the existing methods when a string response isn't needed instead.

Migrate noscript tag into noscriptImg

The tag and usage of dangerouslySetInnerHTML prop would be better suited grouping in the component, especially with ReactDOMServer being added. A TODO comment for v3 refactor can be added to ensure both workarounds can be removed(see review comment for suggested text).

Make DRY (Maintenance) - Optional

noscriptImg should try to reduce duplicate code that can be shared with Img it's already fallen out of sync with some props it seems beyond imgStyle. This isn't super important for this PR as a follow-up can cover refactoring(I'd be happy to do it after existing PRs for this package are reviewed, a few have accumulated lately).

If Img cannot be wrapped effectively due to some differences, it'd be good to extract common props and the base style that is shared.

This snippet might be useful during a refactor.

Comment on lines 220 to +226
function generateNoscriptSources(imageVariants) {
return imageVariants
.map(
variant =>
(variant.srcSetWebp ? generateNoscriptSource(variant, true) : ``) +
generateNoscriptSource(variant)
)
.join(``)
return imageVariants.map((variant, i) => (
<>
{!!variant.srcSetWebp && generateNoscriptSource(variant, true)}
{generateNoscriptSource(variant)}
</>
))
Copy link
Contributor

Choose a reason for hiding this comment

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

As mentioned with the child method called. This would be redundant, use generateImageSources() instead.

Despite that, you're adding i as a map fn param, but you don't use it here. The !!variant should also be unnecessary. None of the logic needs to be modified, just use the non-string generator method instead that we already have.

Comment on lines 243 to +255
const noscriptImg = props => {
// Check if prop exists before adding each attribute to the string output below to prevent
// HTML validation issues caused by empty values like width="" and height=""
const src = props.src ? `src="${props.src}" ` : `src="" ` // required attribute
const sizes = props.sizes ? `sizes="${props.sizes}" ` : ``
const srcSet = props.srcSet ? `srcset="${props.srcSet}" ` : ``
const title = props.title ? `title="${props.title}" ` : ``
const alt = props.alt ? `alt="${props.alt}" ` : `alt="" ` // required attribute
const width = props.width ? `width="${props.width}" ` : ``
const height = props.height ? `height="${props.height}" ` : ``
const crossOrigin = props.crossOrigin
? `crossorigin="${props.crossOrigin}" `
: ``
const loading = props.loading ? `loading="${props.loading}" ` : ``
const draggable = props.draggable ? `draggable="${props.draggable}" ` : ``
const {
src = ``,
alt = ``,
sizes,
srcSet,
title,
width,
height,
crossOrigin,
loading,
draggable,
} = props
Copy link
Contributor

Choose a reason for hiding this comment

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

If leveraging the (almost) same destructure from Img isn't possible, the two components should share what they can through a prop filter method, and then only handle the extra prop extraction that's unique. Note that this noscript variant appears to have fallen out of sync a little, and some props like ariaHidden(aria-hidden) may not spread onto img correctly? Spreading what is possible is probably a good idea though.

Comment on lines +269 to +279
style: {
position: `absolute`,
top: 0,
left: 0,
opacity: 1,
width: `100%`,
height: `100%`,
objectFit: `cover`,
objectPosition: `center`,
...props.imgStyle,
},
Copy link
Contributor

Choose a reason for hiding this comment

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

Share the base style from Img, if the Img component can be re-used instead, just pass in imgStyle instead of imageStyle(adds conditional opacity/transition styles) to the style prop.

Comment on lines +283 to +288
return ReactDOMServer.renderToString(
<picture>
{sources}
<img {...imageProps} />
</picture>
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice workaround :)

I guess this would work until v3, but maybe add a TODO comment about that, like:

// TODO: In v3, minimum required React will be >=16.6, 'dangerouslySetInnerHTML' and 'ReactDOMServer' workarounds will no longer be necessary and can be dropped

Additionally, it would make sense to move the <noscript /> element and it's dangerouslySetInnerHTML prop into this component and return that after providing it the string? Should isolate/group the issue better that way for the v3 refactor.

@@ -568,6 +597,7 @@ class Image extends React.Component {
alt,
title,
loading,
imgStyle,
Copy link
Contributor

Choose a reason for hiding this comment

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

If the fixed/fluid components instead move this noscript tag into it's own component, it could be a wrapper component to Img(or rather the React equivalent picture element also moved out into it's own component, a third component would take in the props to pass to each, along with the two conditional props to control state, generateImageSources() would only need to be called once then).

Not too important, a follow-up PR could do that.

Comment on lines 209 to 218
function generateNoscriptSource({ srcSet, srcSetWebp, media, sizes }, isWebp) {
const src = isWebp ? srcSetWebp : srcSet
const mediaAttr = media ? `media="${media}" ` : ``
const typeAttr = isWebp ? `type='image/webp' ` : ``
const sizesAttr = sizes ? `sizes="${sizes}" ` : ``
const props = {
media,
sizes,
srcset: isWebp ? srcSetWebp : srcSet,
type: isWebp ? `image/webp` : undefined,
}

return `<source ${typeAttr}${mediaAttr}srcset="${src}" ${sizesAttr}/>`
return <source {...props} />
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This change is redundant. Use generateImageSources() instead if string generation is not needed anymore(requires min version of React for the package to be raised to >=16.6, last I knew that change is delayed until the next major version.

@wardpeet
Copy link
Contributor

Closing in favor of #27950, it's a total rewrite and fixes this issue

@wardpeet wardpeet closed this Nov 23, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic: media Related to gatsby-plugin-image, or general image/media processing topics
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[gatsby-image] imgStyle is not propagated down for non-js images
9 participants