Iframe document.write Doesn't Update JavaScript Variables
Asked Answered
D

8

13

I have created a basic HTML editor using a textarea and an iframe to preview HTML, CSS, and JavaScript changes. Here's the setup:

textarea,
iframe {
  width: 300px;
  height: 200px;
}
<textarea></textarea>
<iframe></iframe>
const textarea = document.querySelector('textarea');
const iframe = document.querySelector('iframe');

function preview() {
  const iframeDoc = iframe.contentDocument;
  iframeDoc.open();
  iframeDoc.write(textarea.value);
  iframeDoc.close();
}

textarea.addEventListener('input', preview);

DEMO

The code successfully updates the HTML and CSS content from the textarea into the iframe. However, you can't use JavaScript const or let variables with document.write because it rewrites the entire iframe content, causing a redeclaration error as soon as you edit the inserted code:

Identifier * has already been declared

To reproduce the issue, insert the following sample code into the textarea:

<!doctype html>
<html lang="en">
<head>
  <title>Sample Code</title>
</head>
<body>
  <p>Hello!</p>
  <script>
    const p = document.querySelector('p');
    p.style.color = 'blue';
  </script>
</body>
</html>

Then try changing Hello! to Hello, world!, or blue to red.

What is the best solution to allow users to keep editing the code without encountering this error? Can the try...catch statement be used to handle this specific error, or is there a better approach?

Drupe answered 19/7, 2023 at 20:23 Comment(1)
This is an interesting problem! A quick search gives me similar things people have tried but no real solution. Maybe try deleting and recreating the <iframe> each time?Bannock
R
9

In my opinion, there are two main ways to do this: using the srcdoc attribute (@Kaiido's answer) or using a blob URL. In this answer, I will explain what these options are and how they differ. I will also give you a code example of how to use the blob URL option.

Using srcdoc

The srcdoc attribute lets you write the HTML content of the iframe as a string. This is easy and simple, but it has some problems:

• Some old browsers do not support the srcdoc attribute, so you may need to provide a fallback using the src attribute.

• The HTML content may contain malicious scripts or links that could compromise the security of your main document. You may need to sanitize or escape the HTML content before assigning it to the srcdoc attribute.

So if you don't mind such problems, you might not need this answer because using a blob URL is more complex and indirect than using srcdoc.

Using blob URL

The blob URL method involves creating a new blob object from the HTML content, and then assigning its URL to the src attribute of the iframe. This method can avoid some compatibility and security issues:

• The blob URL is supported by most browsers that support the src attribute, so you do not need to worry about fallbacks.

• The blob URL is separate from the main document and has its own origin and permissions, so it cannot access or modify anything in your main document.

Example:

Here is a code example of how to use the blob URL method. I have used a debounce function to avoid updating the iframe too often, which could cause flickering. You can find more information about the debounce function here.

function preview() {
  var blob = new Blob([textarea.value], { type: "text/html" });
  var url = URL.createObjectURL(blob);
  iframe.onload = function () {
    URL.revokeObjectURL(url);
  };
  iframe.src = url;
}


var debounceTimer;

function debounce(func, delay) {
  return function () {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(func, delay);
  };
}

var debouncedPreview = debounce(preview, 300);

textarea.addEventListener("input", debouncedPreview);

var textarea = document.querySelector("textarea");
var iframe = document.querySelector("iframe");
var debounceTimer;

function debounce(func, delay) {
  return function () {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(func, delay);
  };
}

function preview() {
  var blob = new Blob([textarea.value], { type: "text/html" });
  var url = URL.createObjectURL(blob);
  iframe.onload = function () {
    URL.revokeObjectURL(url);
  };
  iframe.src = url;
}

var debouncedPreview = debounce(preview, 300);

textarea.addEventListener("input", debouncedPreview);
textarea,
iframe {
  width: 400px;
  height: 300px;
}
<textarea></textarea>
<iframe src=""></iframe>

In summary, both the srcdoc and the blob URL methods can be used to create a live preview of HTML content in an iframe. The srcdoc method is simpler but less compatible and secure, while the blob URL method is more complex but more compatible and secure. I prefer the blob URL method because it avoids some potential problems with old browsers and malicious content, but you may choose the method that suits your needs and preferences.

Relume answered 26/7, 2023 at 1:36 Comment(8)
Using srcdoc: As I already mentioned, it causes two other problems: 1) The user can't try creating destination anchors within a page or work with the :target selector. 2) I want a real-time HTML editor, but it causes a delay and the whole iframe content flickers on each input event, which is really annoying, especially on larger HTML documents.Drupe
Using blob URL: There are two problems with this approach: 1) According to MDN Web Docs, each time you call createObjectURL(), a new object URL is created. Each of these must be released by calling URL.revokeObjectURL() for optimal performance and memory usage. 2) It has the same flickering problem as using srcdoc.Drupe
@Mori, thank you for your comments. I understand your concerns and I appreciate your feedback. I agree that both options have some drawbacks, such as needing to create and revoke object URLs or causing flickering in the iframe. You are right that using the URL.revokeObjectURL() method is a good practice to avoid memory leaks, and I thank you for suggesting it. I did not include it in my answer because I thought it was obvious, but maybe I should have mentioned it for clarity. I have updated my answer accordingly. : )Relume
@Mori, Debouncing I mentioned in my answer is one possible solution to avoid flickering. And there are also some possible solutions for the flickering problem, such as hiding, cloning, or caching the iframe.Relume
A blob:URL is also same-origin! If you want the srcdoc to be cross domain you can use the sandbox attribute. Using a blob: URL implies more I/O work. And browser support for srcdoc is like ten years ago. Browser support for blob: URLs as <iframe>'s src is about the same.Freefloating
Sorry to insist, but could you please edit out all the part saying that blob: URLs would be somehow safer than srcdoc? This is completely wrong, and dangerous. blob: URLs are same-origin with the document where they've been created, and such an iframe can access its parent: jsfiddle.net/8L25ofhbFreefloating
Oh and another disadvantage of the blob: solution is that this fills the browser's history at each new version. For instance you may right click the previous button of your browser after running your snippet.Freefloating
"Debouncing I mentioned in my answer is one possible solution to avoid flickering." The blob URL method seems promising, but it still flickers in Chrome and Edge.Drupe
F
7

Set your iframe's srcdoc instead of reopening its Document:

var textarea = document.querySelector('textarea'),
  iframe = document.querySelector('iframe');

function preview() {
  iframe.srcdoc = textarea.value;
}

textarea.addEventListener('input', preview);
textarea,
iframe {
  width: 400px;
  height: 300px;
}
<textarea></textarea>
<iframe></iframe>
Freefloating answered 20/7, 2023 at 1:10 Comment(5)
Thanks for the answer, but it causes two other problems: 1) The user can't try creating destination anchors within a page or work with the :target selector. 2) It causes a delay and the iframe blinks on each input event, which is really annoying, especially on larger HTML documents.Drupe
Those two issues would have held with document.write too.Freefloating
You could defer the rendering of the iframe by using setTimeout to deal with the blinkDight
function preview() { iframe.src = 'about:blank'; iframe.onload = function() { iframe.contentDocument?.write(textarea.value); }; } is just the same as iframe.srcdoc = textarea.value but in a more complicated and slower way, more prone to the "blink" you were talking about, since the browser will wait for the about:blank page to render before setting its content. As for why aEL didn't work I have no idea, you probably messed up somewhere but you don't show and it doesn't seem related to the initial question.Freefloating
That's a good point. So you may use something like debounce. - @DightRelume
D
1

This will allow you to input partial HTML and still render the page in the iframe without the blink.


HTML

<textarea id="input"></textarea>
<iframe id="result"></iframe>

CSS

textarea,
iframe {
    width: 400px;
    height: 300px;
}

JS

const textarea = document.getElementById('input');
let iframe = document.getElementById('result');

function preview() {
    const elem = document.createElement("iframe");
    const dom = (new DOMParser()).parseFromString( textarea.value, 'text/html' );
    
    elem.id = iframe.id
    iframe.replaceWith( elem );
    iframe = elem;
    
    const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
    if ( !iframeDoc ) return null;
    iframeDoc.open();
    iframeDoc.write( dom.documentElement.outerHTML );
    iframeDoc.close();
}

textarea.addEventListener('input', preview);
Dight answered 22/7, 2023 at 4:28 Comment(4)
Your solution works, but wouldn't the following be simpler? function preview() {const iframe = document.createElement('iframe'); document.querySelector('iframe').replaceWith(iframe); iframe.contentDocument.write(textarea.value);}Drupe
Sure you could do it that way but then your using browser resources to find the iframe each time the method is executed. But the way I wrote the code, I keep a reference to the new iframe and therefore do not need to query for it each time an input event is received. Also the reason I transfer the id of the iframe is in case other code needs to find the iframe.Dight
I wonder why you have used the DOMParser parseFromString() method.Drupe
@Mori, I believe it was so that any partial html code could be written to the iframe otherwise you would have to write the full html ( including doctype, html, head, body ) each time.Dight
D
1

Three methods to refresh the iframe and delete JavaScript variables:

replaceWith()

function preview() {
  iframe.replaceWith(iframe);
  const iframeDoc = iframe.contentDocument;
  iframeDoc.write(textarea.value);
  iframeDoc.close();
}

append()

function preview() {
  document.body.append(iframe);
  const iframeDoc = iframe.contentDocument;
  iframeDoc.write(textarea.value);
  iframeDoc.close();
}

outerHTML

let iframe = document.querySelector('iframe');

function preview() {
  iframe.outerHTML = '<iframe></iframe>';
  iframe = document.querySelector('iframe');
  const iframeDoc = iframe.contentDocument;
  iframeDoc.write(textarea.value);
  iframeDoc.close();
}
Drupe answered 27/7, 2023 at 16:16 Comment(1)
Warning: Use of the document.write() method is strongly discouraged. linkLanthanum
M
1

you can't use JavaScript const or let variables

Block Scope Solution (simple)

If you define your const and let declarations within curly braces {} you can avoid the Identifier * has already been declared error.

For example, leave everything how you have it, except within the embedded document, use curly braces within the <script>:

<!doctype html>
<html lang="en">
<head>
  <title>Sample Code</title>
</head>
<body>
  <p>Hello!</p>
  <script>
    {
      const p = document.querySelector('p');
      p.style.color = 'blue';
    }
  </script>
</body>
</html>

You can add as many block scopes as you like:

<!doctype html>
<html lang="en">
<head>
  <title>Sample Code</title>
</head>
<body>
  <p>Hello!</p>
  <a href="#link">page anchor</a>
  <h2 id="link">Link here</h2>
  <script>
    {
      const p = document.querySelector('p');
      p.style.color = 'blue';
    }
    {
      let h2 = document.querySelector('h2');
      h2.style.marginTop = '400px';
    }
  </script>
</body>
</html>

Dynamic iframe Solution (preferred)

Recreating the iframe during the input event also prevents the Identifier * has already been declared error.

Change example HTML to this:

<textarea></textarea>
<div id="iframe"></div>

Change your CSS to this:

textarea,
iframe {
  width: 400px;
  height: 300px;
}
#iframe {
  display: inline-block;
}

And change your script to this:

const framebox = document.getElementById('iframe');
const textarea = document.querySelector('textarea');

function preview() {
  const iframe = document.createElement('iframe');

  framebox.querySelector('iframe')?.remove();
  framebox.appendChild(iframe);

  const iframeDoc = iframe.contentDocument;

  iframeDoc.open();
  iframeDoc.write(textarea.value);
  iframeDoc.close();
}

textarea.addEventListener('input', preview);

Here's an online example (view the page source). This would also work better by using DOMParser to handle the textarea.value as well and that is shown in the example below.

Parsing/RegEx Solution (complex)

You can try a regex or parsing. Of course parsing is going to be more robust.

  1. Use DomParser to parse the embedded document as text/html.
  2. From the parsed document generate an AST from the found script elements text content.
  3. From the AST find all VariableDeclaration nodes and convert them to var declarations using magic-string for convenience.
  4. Update the text content for the script node with the const/let replaced with var.
  5. Update the iframe's content with the updated DomParser object content.

By no means is this fully optimized, but it gives an idea of how you could proceed along this path. For instance, you should store a reference to the identifier values used from the AST so that you can guard/warn against overwriting previous identifiers since var has global scope in this case.

You can use something like serve to load this in your browser, or simply the file:/// protocol.

Demo available at https://morganney.github.io/simple-html-editor/.

<!doctype html>
<html lang="en">
  <head>
    <title>Simple HTML Editor</title>
    <style>
      main {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 15px;
        height: 400px;
      }
      textarea,
      iframe {
        border: 1px solid black;
      }
      iframe {
        width: 100%;
        height: 100%;
      }
    </style>
    <script type="importmap">
      {
        "imports": {
          "acorn": "https://esm.sh/[email protected]",
          "acorn-walk": "https://esm.sh/[email protected]",
          "magic-string": "https://esm.sh/[email protected]"
        }
      }
    </script>
    <script type="module">
      import { parse } from 'acorn'
      import { simple } from 'acorn-walk'
      import MagicString from 'magic-string'

      const textarea = document.querySelector('textarea')
      const constLetRegex = /\bconst\s|\blet\s/g
      const parser = new DOMParser()

      function previewRegex() {
        const iframe = document.querySelector('iframe')
        const iframeDoc = iframe.contentDocument
        const doc = parser.parseFromString(textarea.value, 'text/html')
        const scripts = doc.querySelectorAll('script')

        if (scripts.length) {
          scripts.forEach((script) => {
            script.textContent = script.textContent.replace(constLetRegex, 'var ')
          })
        }

        iframeDoc.open()
        iframeDoc.write(doc.documentElement.innerHTML)
        iframeDoc.close()
      }

      function previewParse() {
        const iframe = document.querySelector('iframe')
        const iframeDoc = iframe.contentDocument
        const doc = parser.parseFromString(textarea.value, 'text/html')
        const scripts = doc.querySelectorAll('script')

        if (scripts.length) {
          scripts.forEach((script) => {
            const magic = new MagicString(script?.textContent || '')
            let ast = null

            try {
              ast = parse(script.textContent, {
                ecmaVersion: 2023,
                sourceType: 'module'
              })
            } catch {
              /* do nothing */
            }

            if (ast) {
              simple(ast, {
                VariableDeclaration(node) {
                  magic.overwrite(node.start, node.start + node.kind.length, 'var')
                }
              })

              script.textContent = magic.toString()
            }
          })
        }

        iframeDoc.open()
        iframeDoc.write(doc.documentElement.innerHTML)
        iframeDoc.close()
      }

      textarea.addEventListener('input', previewRegex)
      // Or use a RegEx
      // textarea.addEventListener('input', previewRegex)
    </script>
  </head>
  <body>
    <main>
      <textarea></textarea>
      <iframe></iframe>
    </main>
  </body>
</html>

Can I use the try...catch statement to define a function only for this specific error?

Yes and no.

If you do something like this:

try {
  iframeDoc.write();
} catch (err) {}

The error will be fired against the iframe's window and the parent window will not catch that. You'd have to listen to the error event on a reference to the iframe's window:

iframeDoc.defaultView.addEventListener('error', (err) => {
  if (/has already been declared/.test(err.message) {
    const scopeErrorOccured = true;
  }
}); 

However, the error will only be thrown after the call to iframeDoc.write(textarea.value) so you're back to the same condition of the iframe not updating the content because of the uncaught SyntaxError. Kind of a catch-22.

Mephistopheles answered 7/5 at 0:36 Comment(5)
Your second solution doesn't work.Drupe
@Drupe last time I checked the second solution worked. I’ll host that one online too.Mephistopheles
@Drupe here's a link to an online example of the second solution: morganney.github.io/simple-html-editor/dynamic.html. I also updated the answer to include the link. Mind explaining what aspect of it "doesn't work" for you?Mephistopheles
Thanks for adding a demo! All your snippets were complete except for the script of the second solution. I just edited your answer.Drupe
@Drupe I guess keeping the textarea declaration the same was implied, but your edit makes it more explicit, which is probably better. Your question was interesting and fun to work through.Mephistopheles
A
0

As far as I know, there is no way to un-declare variables in JS or remove them from a document's scope. I'd suggest dynamically recreating an iframe element on each input event. Something like this:

// index.js
const textarea = document.querySelector("textarea");

function preview() {
  const container = document.getElementById("iframe-container");
  const existing = document.querySelector("iframe");

  if (existing) existing.remove();

  const iframe = document.createElement("iframe");
  container?.appendChild(iframe);
  const iframeDoc = iframe?.contentDocument;

  iframeDoc?.open();
  iframeDoc?.write(textarea?.value ?? "");
  iframeDoc?.close();
}

textarea?.addEventListener("input", preview);
<!-- index.html -->
<textarea></textarea>
<div id="iframe-container">
  <iframe></iframe>
</div>
Ailsun answered 19/7, 2023 at 23:5 Comment(3)
"there is no way to un-declare variables in JS"- there kinda is: the delete operator. Top-level var properties exist as global properties, so delete foo is the same as delete window.foo - and so should remove a var from scope (though not let or const as those aren't promoted to global properties).Extraterritoriality
I don't understand why you have used the optional chaining operator. And wouldn't it be simpler to put your idea like this? function preview() {document.querySelector('iframe').remove(); const iframe = document.createElement('iframe'); document.body.appendChild(iframe); iframe.contentDocument.write(textarea.value);}Drupe
@Drupe without the optional chaining, you'd be making assumptions that the elements exist when that code is executed. Try writing my example in Typescript (like I did) and you'll get warnings that 'element' is possibly 'null'. You'll get the same for your document.querySelector('iframe').remove(); suggestion which I could have slightly simplified by writing document.querySelector('iframe')?.remove(); (notice the optional chain).Ailsun
N
0

The issue you are encountering is due to the fact that every time you call iframeDoc.write(textarea.value), it completely recreates the content of the iframe, including its JavaScript context. This means that when you modify the content in the textarea, the new JavaScript code conflicts with the previously declared variables, causing the "Identifier has already been declared" error.

To overcome this issue and allow the user to keep editing the code without getting that error, you can use the document.createElement method to create new script elements and add them to the iframe document's head. This way, you won't be overwriting the entire iframe content, and the JavaScript context will be preserved.

var textarea = document.querySelector('textarea'),
  iframe = document.querySelector('iframe');

function preview() {
  var iframeDoc = iframe.contentDocument;
  iframeDoc.open();
  iframeDoc.write(textarea.value);
  iframeDoc.close();

  // Extract and execute script tags in the iframe separately
  var scriptElements = iframeDoc.getElementsByTagName('script');
  for (var i = 0; i < scriptElements.length; i++) {
    eval(scriptElements[i].text);
  }
}

textarea.addEventListener('input', preview);
textarea,
iframe {
  width: 400px;
  height: 300px;
}

With this approach, when you update the content in the textarea, it will only update the HTML and CSS parts in the iframe using iframeDoc.write(). The JavaScript code inside the iframe will be extracted and executed separately using eval() on the script tags, allowing the user to keep editing the code without encountering the "Identifier has already been declared" error.

Nepheline answered 23/7, 2023 at 18:26 Comment(2)
Would you mind providing a working demo?Drupe
This answer will fail when you try to access variables declared in two (or more) different <script/> blocks, also the Identifier xx has already been declared error will still be thrown, nice try thoughDoenitz
D
0

To achieve the goals of direct editor without the flickering effect of the iframe re-render you would probably have to use the direct write api, it seems to me that the only issue write() have is with const and let, means if we could use the good old var everything else should work.

I use here regex to replace const and let with var.

Test this HTML locally as this cannot run on the stackoverflow environment

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        body, html {
            margin: 0;
            padding: 0;
            height: 100%;
        }
        textarea, iframe {
            width: 100%;
            height: 70%;
            border: 1px solid black;
            box-sizing: border-box;
        }
    </style>
</head>
<body>
    <textarea id="htmlEditor" placeholder="Type HTML here..." style="width: 100%; height: 200px;"></textarea>
    <iframe id="iframe" style="width: 100%; height: 300px; border: 1px solid black; margin-top: 20px;" >></iframe>

    <script>
        const htmlEditor = document.getElementById('htmlEditor');
        const iframe = document.getElementById('iframe');

        const initialContent = `
            <html>
            <body>
                <h1>I am inside an iframe</h1>
                <p>Start editing...</p>
                <script>
                    const foo = document.querySelector('p');
                    foo.style.color = 'green';
                <\/script>
            </body>
            </html>
        `;

        function updateIframe() {
            const htmlContent = htmlEditor.value;
            // Replace let and const with var in script content directly within the HTML string
            const processedContent = htmlContent.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, function(match) {
                return match.replace(/\b(let|const)\s/g, 'var ');
            });

            const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
            iframeDoc.open();
            iframeDoc.write(processedContent);
            iframeDoc.close();
        }

        htmlEditor.addEventListener('input', updateIframe);
        htmlEditor.value = initialContent;
        document.addEventListener('DOMContentLoaded', updateIframe);
    </script>
</body>
</html>
Doublet answered 7/5 at 7:50 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.