Building an iFrame component for a React application: an unexpected journey

or, how to get Safari/Webkit to listen to events in an iFramed document

Published Sun Mar 06 2022

Recently I was tasked with adding functionality to a GatsbyJS site that would allow isolating component previews from the rest of the site’s code. My initial idea to accomplish this was to try using the shadow DOM. I was familiar enough with the shadow DOM offering CSS isolation powers to web components, so it naturally followed (at least in my mind) that it did the same for javascript. I wasn’t sure though.

I had it on good authority that iFrame could definitely do the job, so I thought, “Why not. If it fails there’s always the shadow DOM.”

My first crack at it involved using the npm package react-frame-component to provide all my iframe needs. This worked well enough with the exception of the iframe heights defaulting to 150px. For our needs the heights had to be dynamically set, after the iframe’s document had loaded. Eventually I got this working by using setTimeout in useEffect to:

  • get a reference to the iFrame
  • get the height of the frame’s document from its contentWindow
  • set the iFrame’s height

But setTimeout’s are ew and we can do so much better than this:

const PreviewComponent = () => {
  const frameRef = useRef()
  const [frameHeight, setFrameHeight] = useState('auto')

  useEffect(() => {
    const TIMEOUT = 500
    console.log(frameRef.current) // noop, not yet
    setTimeout(() => {
      console.log(frameRef.current) // yerp, now we have something
      setTimeout(() =< {
        const height = frameRef.current.contentWindow?.whereeverHeightIsInTheDOM
        setFrameHeight(height + 'px')
      }, TIMEOUT)
    })
  })

  return <Frame ref={frameRef} height={frameHeight}> <StuffInAFrame/> </Frame>

}

After a bit more trial and error I settled on something closer to this, which got rid of the timeout[^1] and improved speed of setting the frame’s height a bit:

const PreviewComponent = () => {
  const frameRef = useRef()
  const [frameHeight, setFrameHeight] = useState('auto')

  const getTheHeight = () => {
    if (frameRef.current.contentWindow && frameHeight === 'auto') {
      const height = frameRef.current.contentWindow.whereeverHeightIsInTheDOM
      setFrameHeight(height + 'px')
    } else {
      // new browser context
      setTimeout(getTheHeight)
    }
  }
  
  useEffect(() => {
    console.log(frameRef.current) // noop, not yet
    setTimeout(() => {
      console.log(frameRef.current) // yerp, now we have something
      setTimeout(() =< {
        getTheHeight()
    })
  })

  return <Frame ref={frameRef} height={frameHeight} > <StuffInAFrame/> </Frame>
}

I decided this was good enough. It got the job done even though there were some deficiencies. One of them being that it was slow. Unfortunately there were still bugs. And there was still that setTimeout. I was using the setTimeout to switch to a different execution context and increase the likelihood that the thing I was looking for was there. But it was still a bit of a hack.

Plan B

xkcd comic - How to write good code

I didn’t think I’d be able to get the kind of performance I wanted by using the npm plugin so then I tried to write one from scratch. Doing this allowed me to encapsulate the height adjustment logic within the component itself so I was happy with that. And to get the actual height after loading I decided to use Event Listeners. DOM events would get triggered on events like loaded or DOMContentLoaded and then I’d query for the height.

AND IT WORKED!

The code looked something like this:

const Frame = ({children}) => {
  const frameRef = useRef()
  const [frameHeight, setFrameHeight] = useState('auto')

  const setTheHeight = () => {
    if (frameRef.current.contentWindow && frameHeight === 'auto') {
      const height = frameRef.current.contentWindow.whereeverHeightIsInTheDOM
      setFrameHeight(height + 'px')
    }
  }
  
  useEffect(() => {
    frameRef.current?.addEventListener('load', () => {
      setTheHeight()
    })
    frameRef.current?.addEventListener('resize', () => {
      setTheHeight()
    })
    
    return () => {
	    window.removeEventListener('load', () => {
	      setTheHeight()
	    })
	    window.removeEventListener('resize', () => {
	      setTheHeight()
	    })
    }
  })

  return <iframe ref={frameRef} height={frameHeight} > {children} </Frame>
}

Well, it worked until I started testing in Safari where it utterly failed.

After recovering from the shock of a modern browser not supporting what I assume was basic functionality, I got back to work and tracked the issue down to an UNCONFIRMED 12 year old Webkit bug.

Now that I had an idea of where the problem was I could start looking at potential solutions. As fortune had it I’d just started working with a new developer that had quite a bit of knowledge of working with Safari. He’d apparently run into a similar issue, and solved it by passing messages between the parent and frame documents.

How to get Safari/Webkit to listen to events for an iFramed document

Armed with knowledge of the web messaging API I contemplated (after it was suggested by a colleague) using it to solve the issue without setTimeouts (which did still work). This strategy, which may have ended up working, was sending a message to the parent document from the frame once it was loaded. When the parent received the message (via event listener) it would send a message back to the frame. After receiving the message, the frame would (via event listener) try to get the height. It may have borne fruit, but I didn’t take that train of thought to its final destination.

Frankly, I don’t believe this is a problem we should be solving today, and since I stumbled upon an alternate solution in a prior iteration with setTimeout I declared defeat used that for Safari. The final solution looked a little something like the code below:

const Frame = ({children}) => {
  const frameRef = useRef()
  const [frameHeight, setFrameHeight] = useState('auto')

  const setTheHeight = () => {
    if (frameRef.current.contentWindow && frameHeight === 'auto') {
      const height = frameRef.current.contentWindow.whereeverHeightIsInTheDOM
      setFrameHeight(height + 'px')
    }
  }

  useEffect(() => {
    frameRef.current?.addEventListener('load', () => {
      setTheHeight()
    })
    frameRef.current?.addEventListener('resize', () => {
      setTheHeight()
    })

    // Safari only code
    seTimeout(() => {
      // setTimeout creates a new execution context, and height's available here
      if(isSafariBrowser()) {
        setTheHeight()
      }
    })

    return () => {
	    frameRef.current?.removeEventListener('load', () => {
	      setTheHeight()
	    })
	    window.removeEventListener('resize', () => {
	      setTheHeight()
	    })
    }
  })

  return <iframe ref={frameRef} height={frameHeight} > {children} </Frame>
}

So Safari gets hacky code (from me, you could do better), is that it? It doesn’t have to be!

I call on you, my fellow developers to file a Feedback with Apple and reference webkit ticket https://bugs.webkit.org/show_bug.cgi?id=33604. Even if the Webkit developers decide they won’t fix it, a status acknowledging this problem (and maybe suggesting a solution) is far better than the current state of affairs.

[^1]: I’m also doing this from memory so don’t trust this code 100%. Follow the concept, not the code.