1

On my Gatsby site I want a Youtube component that only shows up if the user has accepted cookies, because of GDPR. If they have declined cookies I want it to give guidance for resetting their preferences, and if cookie acceptance state is unknown (say they're using an extension that has blocked the cookie notice) I want it to say that it doesn't know whether cookies are allowed or not and if you don't see the banner then please check your adblocker.

I have already set up global state via react-redux that checks for the 'acceptCookies' cookie and all the logic for displaying the banner etc (it's all done using react-cookie-consent) and sets state.acceptCookies to true. This has worked on various pages where for example I only want a contact form to show up if cookies are accepted.

This is my component (which I include in any page which is to have a Youtube video, passing it the Youtube code as a prop) :

import React from "react"
import { useSelector } from "react-redux"
import { Alert } from "react-bootstrap"
import { Link } from "gatsby"

const YouTubeVideo = (props) => {
  const acceptCookies = useSelector(state => state.acceptCookies)
  return (
    <>
      {acceptCookies === '' &&
        <Alert variant="warning">
          <p>
            We cannot show this YouTube video as cookies  have not been accepted,
            but are required for YouTube to work.  To comply with GDPR law, the
            video is not displayed until you have accepted cookies.
        </p>
          <p>
            If you do not see the cookie banner, please try resetting your
          preferences from the <Link to="/privacy-policy">Privacy Policy</Link> or
          disabling any content blockers.
        </p>
        </Alert>
      }
      {acceptCookies === 'false' &&
        <Alert variant="warning">
          <p>
            We cannot show this YouTube video as cookies have been declined, but
            are required for YouTube to work.  To comply with GDPR law, the video
            is not displayed.
        </p>
          <p>
            You may reset your preferences from
          the <Link to="/privacy-policy">Privacy Policy</Link>
          </p>
        </Alert>
      }
      {acceptCookies === 'true' &&
        <div className="embed-container">
          <iframe src={`https://www.youtube.com/embed/${props.code}?rel=0`}
            title={`YouTube Video ${props.code}`}
            frameBorder="0"
            allowFullScreen
          >
          </iframe>
        </div>
      }
    </>
  )
}

export default YouTubeVideo

I've tried other syntax such as using if statements and multiple render blocks, no difference

I've tried rewriting it as a class and using connect(), no difference

For the life of me I can't get it to work properly. It works fine in develop mode - initial state shows the 'cookies haven't been accepted' alert, declining shows the 'cookies have been declined' alert, and accepting shows the video, and all is well and good. I refresh and things continue to behave.

But once I build and deploy the site it behaves really weird. It seems fine at first - initial state shows the 'cookies haven't been accepted' alert, declining shows the 'cookies have been declined' alert, and accepting shows the video. And then you refresh the page and.... it shows a small version of the video inside an alert! (With no alert text). It's really odd. It's like it's following the 'cookies not accepted' logic and starting to draw an alert, and then before writing in the paragraphs, hitting some kind of race condition and rendering the 'cookies accepted' part of the code i.e. the video. I don't get it at all, I'd expect it to render one of the 3 things, not somehow manage to mash the outcomes together.

What am I missing here? I'm completely baffled. I think I must be tripping over something in the pre-rendering, but just can't get my head around it.

Edit: With a clearer head I thought to at least try setting acceptCookies to a static 'true' to see whether the problem is with my rendering (in which case it'd still not display correctly) or the global state implementation (in which case it would) and proved that it's the latter. The problem really is that I don't know how to properly do cookie permission or global state in Gatsby ("caching" it as global state so that upon acceptance of cookies the output will update without having to refresh the page) and could only really go off a mash-up of tutorials about vanilla React and Gatsby. I'm probably best off rewriting it - at this point I can't remember which tutorial(s) I used!

To add though, this is what I have

gatsby-ssr.js and gatsby-browser.js both contain:

import wrapWithProvider from "./wrap-with-provider"
export const wrapRootElement = wrapWithProvider

wrap-with-provider.js:

import React from "react"
import { Provider } from "react-redux"

import createStore from "./src/state/createStore"

// eslint-disable-next-line react/display-name,react/prop-types
export default ({ element }) => {
  // Instantiating store in `wrapRootElement` handler ensures:
  //  - there is fresh store for each SSR page
  //  - it will be called only once in browser, when React mounts
  const store = createStore()
  return <Provider store={store}>{element}</Provider>
}

src/state/createStore.js:

import { createStore as reduxCreateStore } from "redux"
import Cookies from 'js-cookie'

const reducer = (state, action) => {
  if (action.type === `ACCEPT_COOKIES`) {
    return Object.assign({}, state, {
      acceptCookies: 'true',
    })
  }
  if (action.type === `DECLINE_COOKIES`) {
    return Object.assign({}, state, {
      acceptCookies: 'false',
    })
  }
  if (action.type === `RESET_COOKIES`) {
    return Object.assign({}, state, {
      acceptCookies: '',
    })
  }
  return state
}

const initialState = { acceptCookies: Cookies.get('acceptCookies') || '' }

const createStore = () => reduxCreateStore(reducer, initialState)
export default createStore

The cookie consent notice is part of layout.js so that it's on every page whenever it's needed:

import CookieConsent from "react-cookie-consent"

const OurCookieConsent = ({ acceptCookies, accept, decline }) => (
  <CookieConsent
    location="bottom"
    enableDeclineButton={true}
    cookieName="acceptCookies"
    onDecline={decline}
    onAccept={accept}
  >
    <p>This website uses cookies for:</p>
    <ul>
      <li>REQUIRED: Saving your cookie preferences</li>
      <li>OPTIONAL: The contact form on the "Contact Us" page - for spam
      control (reCAPTCHA).</li>
      <li>OPTIONAL: Embedded YouTube videos e.g. videos in our news pages
      </li>
    </ul>
    <p>Declining consent will disable the features marked as OPTIONAL.</p>
    <p>For more in-depth detail or to withdraw or re-establish your
    consent at any time, please see the <Link to="/privacy-policy">Privacy Policy</Link>.</p>
  </CookieConsent>
)

OurCookieConsent.propTypes = {
  acceptCookies: PropTypes.string.isRequired,
  accept: PropTypes.func.isRequired,
  decline: PropTypes.func.isRequired,
}

const mapStateToProps = ({ acceptCookies }) => {
  return { acceptCookies }
}

const mapDispatchToProps = dispatch => {
  return {
    accept: () => dispatch({ type: `ACCEPT_COOKIES` }),
    decline: () => dispatch({ type: `DECLINE_COOKIES` })
  }
}

const ConnectedCookieConsent = connect(mapStateToProps, mapDispatchToProps)(OurCookieConsent)

Then ConnectedCookieConsent is included inside the layout.

derykmarl
  • 31
  • 4

2 Answers2

2

I seem to have got it working. It's down to the efficiency of how Gatsby works for production sites - it pre-renders a lot of the HTML via Node.js and then when the hosted site loads and hydrates it only updates what it thinks it needs to. It doesn't realise that the whole pre-rendered 'outer' div (which was rendered in the 'unknown' state since there are no cookies during SSR) needs to be re-rendered and only updates where it's certain something has changed: the inner content.

So the component needs to "know" to FULLY refresh itself which it will only do if there's a change in state or props. An initial hydration doesn't count as a change (because the gatsby opinion is that the application shouldn't care whether it's in SSR or browser), so it's left in that sort of "limbo". You have to trigger a state change, which you can apparently do with a useEffect() hook but personally I found easiest by converting it to a class and using componentDidMount(). That state then needs to be used in the render function, so that it knows "if the state changes then I need to re-render myself". If you want the video to display immediately after the cookie notice is accepted (without a refresh) then you also need componentDidUpdate() (and need to only update state if it's different, otherwise it causes an infinite loop, hence the if statement in the below code).

It's very complicated and I still don't 100% understand it but that's my rough understanding of the situation based off answers to this question:

Can you force a React component to rerender without calling setState?

and this one:

https://github.com/gatsbyjs/gatsby/issues/12413

My working component is thus:

import React from "react"
import { connect } from "react-redux"
import { Alert } from "react-bootstrap"
import { Link } from "gatsby"

function mapStateToProps(state) {
  return { acceptCookies: state.acceptCookies }
}

class YouTubeVideo extends React.Component {
  state = {
    acceptCookies: 'undecided',
  }

  componentDidMount() {
    this.setState({
      acceptCookies: this.props.acceptCookies,
    })
  }

  componentDidUpdate() {
    if (this.state.acceptCookies !== this.props.acceptCookies) {
      this.setState({
        acceptCookies: this.props.acceptCookies,
      })
    }
  }

  render () {
    const { acceptCookies } = this.state
    if (acceptCookies === 'undecided') {
      return (
          <Alert variant="warning">
            <p>
              We cannot show this YouTube video as cookies  have not been accepted,
              but are required for YouTube to work.  To comply with GDPR law, the
              video is not displayed until you have accepted cookies.
            </p>
            <p>
              If you do not see the cookie banner, please try resetting your
              preferences from the <Link to="/privacy-policy">Privacy Policy</Link> or
              disabling any content blockers.
            </p>
          </Alert>
      )
    }
    if (acceptCookies === 'false') {
      return(
          <Alert variant="warning">
            <p>
              We cannot show this YouTube video as cookies have been declined, but
              are required for YouTube to work.  To comply with GDPR law, the video
              is not displayed.
            </p>
            <p>
              You may reset your preferences from
              the <Link to="/privacy-policy">Privacy Policy</Link>
            </p>
          </Alert>
      )
    }

    if (acceptCookies === 'true') {
      return (
          <div className="embed-container">
            <iframe src={`https://www.youtube.com/embed/${this.props.code}?rel=0`}
              title={`YouTube Video ${this.props.code}`}
              frameBorder="0"
              allowFullScreen
            >
            </iframe>
          </div>
      )
    }
    return (
      <p><a href={`https://www.youtube.com/watch?v=${this.props.code}`}>YouTube Video</a></p>
    )
  }
}


export default connect(mapStateToProps)(YouTubeVideo)

So to recap....

The component has its own local state with acceptCookies initially set to 'undecided' which is what SSR will generate (and what Google or people with JS disabled will end up seeing) and it's also what you see for a few ms on load if you have the cookie, until the JS workers load and the page hydrates. This does cause a brief flash of unwanted content or FOUC, but I don't think there's anything one can do about that and still be GDPR compliant. It needs the javascript to load to check the cookies and know it has permission to render the YouTube embed (and the plethora of otherwise unsolicited cookies that Google dumps on the user, hence requiring this entire exercise). As soon as it hydrates, componentDidMount() is called and updates the local state to the current acceptCookies prop (which we'll get to in a moment). If it's different (i.e. you've already accepted cookies), then the component sees the state change and re-renders itself in full.

At the bottom you see that the component is exported via connect(mapStateToProps), which takes the global state (context) from redux, passes it through the mapStateToProps function which spits out the acceptCookies prop which is then bound back to the component. This, fortunately, happens before componentDidMount(), and so this is how it knows to update the state correctly.

When the acceptCookies cookie is updated (via the separate cookie notice mechanism I use), we already know that the 'ACCEPT_COOKIES' reducer in createStore.js is fired, causing the redux state to update to acceptCookies='true'. The connect function then updates the component's props to match (similar to how it worked before) but because we're now using state rather than props in the render function, this isn't enough to trigger a re-render - we also need componentDidUpdate() to pick up on the props change and update the state to match.

Correct me if I've done something silly, but this seems to have done the trick. I hope the verbosity helps someone else in my situation.

I like the fallback I came up with of just providing a link (since under GDPR you're not responsible for the cookies another site places as long as it's an external link, it's just if you're embedding their site as an iframe or such it's considered "part" of yours), and in the end will probably update my false/undecided output to do that (and provide a small explanation) rather than render a big in-your-face alert and deny the user the opportunity to see the video.

derykmarl
  • 31
  • 4
0

I'd write the YoutubeVideo component like this instead:

import React from "react"
import { useSelector } from "react-redux"
import { Alert } from "react-bootstrap"
import { Link } from "gatsby"

const YouTubeVideo = (props) => {
    const acceptCookies = useSelector((state) => state.acceptCookies)

    if (acceptCookies === "")
        return (
            <Alert variant="warning">
                <p>
                    We cannot show this YouTube video as cookies have not been
                    accepted, but are required for YouTube to work. To comply
                    with GDPR law, the video is not displayed until you have
                    accepted cookies.
                </p>
                <p>
                    If you do not see the cookie banner, please try resetting
                    your preferences from the{" "}
                    <Link to="/privacy-policy">Privacy Policy</Link> or
                    disabling any content blockers.
                </p>
            </Alert>
        )

    if (acceptCookies === "false")
        return (
            <div className="embed-container">
                <iframe
                    src={`https://www.youtube.com/embed/${props.code}?rel=0`}
                    title={`YouTube Video ${props.code}`}
                    frameBorder="0"
                    allowFullScreen
                ></iframe>
            </div>
        )

    if (acceptCookies === "true")
        return (
            <div className="embed-container">
                <iframe
                    src={`https://www.youtube.com/embed/${props.code}?rel=0`}
                    title={`YouTube Video ${props.code}`}
                    frameBorder="0"
                    allowFullScreen
                ></iframe>
            </div>
        )

    // Maybe add a default ?
    return (
        <Alert variant="warning">
            <p>
                Not sure what's up here, but hey, better safe than sorry :)
            </p>
        </Alert>
    )
}
B. Cole
  • 124
  • 8
  • Thanks for the response... I've given this a try, it does look neater in any case, but with the same result that after a refresh I get a youtube video inside an alert box. I've even tried rewriting the entire state management from react-redux to plain react using the tutorial here: https://dev.to/changoman/gatsby-js-global-state-w-react-context-usereducer-3c1 Still the same. I'm completely and totally baffled, having changed basically everything and yet still getting the same result. It feels like it's bordering on supernatural at this point :) – derykmarl Jul 15 '20 at 10:21
  • Just as a matter of interest I simplified the code to just put a paragraph "cookies unknown", "cookies refused" or "cookies accepted" and on refresh there's a brief flash of "cookies unknown" before it says "cookies accepted". I think that's the root of my problem and for whatever reason the alert is 'sticking' during this momentary flash. I've now boiled it down to the line: ```const initialState = { acceptCookies: Cookies.get('acceptCookies') || '' }``` in createStore.js - I have a feeling I shouldn't be initialising the state in that way. js-cookie is causing a delay. – derykmarl Jul 15 '20 at 10:43
  • I think you're right in your latest comment. When `js-cookie` doesn't find a cookie, it returns `undefined` (as per their documentation). So you'd be better off writing something like `const initialState = { acceptCookies: Cookies.get('acceptCookies') }` and instead of checking for an empty string in your component, check for undefined: `if (acceptCookies === undefined) return `. My initial response is kinda missing the point, sorry about that – B. Cole Jul 15 '20 at 12:39