1

I'm trying to append an iframe element into document.body as soon as possible.

Error reports are coming in across all 4 major browsers (Chrome, Safari, FF, IE --various versions), we see document.body is null. I think this happens when my JS file is cached and loads quickly.

Here is the logic to insert the iframe:

private loadIFrame(): void {
  switch (document.readyState) {
    case 'uninitialized':
    case 'loading':
    case 'loaded':
      window.addEventListener('DOMContentLoaded',
        this.appendIFrame.bind(this)
      )
      break
    case 'interactive':
    case 'complete':
    default:
      this.appendIFrame()
  }
}

private appendIFrame(): void {
  if (document.getElementById('iframeId')) {
    return
  }

  let iFrame: HTMLIFrameElement = document.createElement('iframe')
  iFrame.src = document.location.protocol + this.ORIGIN + '/iframe.html'
  iFrame.id = 'iframeId'

  // document.body is null here
  document.body.appendChild(iFrame)
}

I'm having a hard time reproducing the issue in a clean environment, which leaves me guessing how this happens out in the wild.

I originally tried this rreadyState logic, but we saw document.body was undefined in IE when in the loading state.

private loadIFrame(): void {
  switch (document.readyState) {
    case 'uninitialized':
    case 'loading':
      window.addEventListener('DOMContentLoaded',
        this.appendIFrame.bind(this)
      )
      break
    case 'loaded':
    case 'interactive':
    case 'complete':
    default:
      this.appendIFrame()
  }
}

My current line of questioning....

  1. Is the issue the default case? Should I add the event listener there? I could modify the order of the cases so that the event listener is default?
  2. Is it possible body is null on the DOMContentLoaded event?
  3. Is it possible there are other values of document.readyState that are falling through?
theUtherSide
  • 3,338
  • 4
  • 36
  • 35
  • Adding a few references. I believe DOMContentLoaded is the correct event for the earliest time when body is guaranteed to be defined: https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded Also, recognizing that not all browsers use all 5 of the readStates. The latest standard only has 3: https://developer.mozilla.org/en-US/docs/Web/API/Document/readyState – theUtherSide Jun 29 '17 at 22:52
  • Here's the reference for IE that has the "uninitialized" and "loaded" states. To me, this indicates body is not available in the "loaded" state, because parsing is not complete. https://msdn.microsoft.com/en-us/library/ms534359(v=vs.85).aspx – theUtherSide Jun 29 '17 at 22:55
  • 1
    Note that this is not 100% reliable. I've encountered components (e.g., YUI rich text editor) that would remove and reinsert the `body` element, so you can have a case where readyState = complete, but there will be a small window where contentDocument.body = null (I've seen it null as late as 100 ms after readyState = complete for this particular component, but YMMV especially if you're loading from an external server). Use a mutation observer if you want something more full-proof. – thdoan May 24 '18 at 19:46
  • That explains so much of the odd behavior I was seeing. I knew some browsers had bugs in when `readyState` would change, but I had not considered that other JS would be boinking the `` entirely. This explains why my solution below never worked 100% as well. – theUtherSide May 24 '18 at 19:49
  • theUtherSide, yeah I'm not sure what happens underneath, but I reckon when they inject the iframe the body is there by default (?), and then there's code to insert a ``, so essentially the initial one gets removed and reinserted. – thdoan May 24 '18 at 19:59
  • 1
    I was able to reproduce this again just now using the YUI rich text editor, and this might help you. Here's the initial output of `console.log(iframe.contentDocument.readyState, iframe.contentDocument.body)`: `"complete" `. Here's the same output 100 ms later: `"loading" null`. This tells me after the iframe has completed loading, that doesn't mean it can't go back to a "loading" state :-). In theory what you can do is to a `setTimeout` for 100 ms, then inside the callback you can detect `readyState` again. – thdoan May 24 '18 at 20:21

2 Answers2

1

First, if you are already checking the readyState and have determined it to be loaded, then why are you setting up an event handler for a moment that has already passed (DOMContentLoaded)?

You could just do:

private loadIFrame(): void {
  switch (document.readyState) {
    case 'uninitialized':
    case 'loading':
    case 'loaded':
      this.appendIFrame.bind(this);
      break;
    case 'interactive':
    case 'complete':
    default:
      this.appendIFrame();
  }
}

Next, your event handler registration is wrong. There are 3 arguments to .addEventListener() and the third can be one of two values:

  1. The event name (string)
  2. The callback function (reference or inline function)

    3a. Whether to hook into the capture phase (boolean - false by default)

    3b. An options object to configure characteristics (object)

And, addEventListener() itself is called on the object that will receive the event (window in this case).

It should be:

    window.addEventListener('DOMContentLoaded', function(){
      this.appendIFrame.bind(this);
    });

Also (FYI), you really should manually insert your end of statement semi-colons and not rely on automatic insertion as there are edge cases where that causes bugs.

Scott Marcus
  • 64,069
  • 6
  • 49
  • 71
  • Thank you so much for your input! My actual code uses a cross-browser wrapper method and I just beefed up the translation of the parameters. I was trying to simplify the post a bit. – theUtherSide Jun 29 '17 at 22:40
  • Similarly, we do not use ASI, rather we transpile TypeScript into ES5, so the source does not have semicolons, but the distributed code does. – theUtherSide Jun 29 '17 at 22:41
  • @theUtherSide Nonetheless, you seem to be attempting to handle the event after you've determined that the event has already taken place. Does my first code example work for you? – Scott Marcus Jun 29 '17 at 22:42
  • With the fall-through switch statement, the event listener is added when the readyState is any of the first three values ('uninitialized', 'loading', or 'loaded'). It is my understanding that "loaded" is an IE-specific state that is not always used, but it should still hit that case when it's either of the first two values. At those stages, body is not yet available. I'm expecting body is defined when the readyState is 'interactive' or 'complete' – theUtherSide Jun 29 '17 at 22:47
1

I updated the logic, and I was able to almost eliminate the errors, but I am still seeing document.body is null when readyState is interactive.

I'll be updating this post once I assess the specific browser scenarios that cause the issue.

private loadIFrame(): void {
  switch (document.readyState) {
    case 'uninitialized':
    case 'loading':
    case 'loaded':
      document.addEventListener('DOMContentLoaded',
        this.appendIFrame.bind(this)
      )
      break
    case 'interactive':
    case 'complete':
    default:
      if(document.body) {
        this.appendIFrame()
      } else {
        window.addEventListener('load',
          this.appendIFrame.bind(this)
        )
      }
  }
}
theUtherSide
  • 3,338
  • 4
  • 36
  • 35
  • it's my mouse, it's defective. It double clicks all the time, even if I only single clicked. will have to wait until I got new mouse. – mathheadinclouds Sep 26 '19 at 18:39