Seamless Webviews in Electron

Electron has a few different ways to embed web content safely into an existing window. The standard technique is to use a regular iframe, but this doesn’t give you all the power you might need over user content; notably, you don’t get full control over the will-navigate event for user navigation.

The documentation notes that you can use WebViews or regular BrowserWindows. WebViews have the distinct advantage that they appear in the regular DOM, and hence are flowed as part of the document’s content. They’re also underdocumented and not entirely recommended, but they’re the best balance between an iframe and a BrowserWindow that Electron offers right now.

For my particular use case, I want to render user content with the following key requirements:

  • User content is supplied as a string of HTML.
  • User content should be restricted as much as possible. Notably, no user-supplied JavaScript should be run.
  • Navigation events must be caught and sent to the system; i.e. clicking a link in user content should open the system browser.
  • User content should be seamlessly displayed as part of the parent layout flow, without additional scrolling.

The solution I settled on for this was a webview with JavaScript disabled, encoding the content into a data URL:

<webview
    enableremotemodule="false"
    src="data:text/html,..."
    webpreferences="javascript=no"
/>

This works great and solves the first two items neatly. The third item is also easily solved by attaching events in the main process:

app.on( 'web-contents-created', function ( event, contents ) {
    if ( contents.getType() !== 'webview' ) {
        return;
    }

    // Handle browsing inside a webview.
    const handleLink = function ( event, url ) {
        event.preventDefault();
        shell.openExternal( url );
    };
    contents.on( 'will-navigate', handleLink );
    contents.on( 'new-window', handleLink );
} );

However, the fourth item is where it gets tricky. We need to detect the inherent size of the user content, and then change the height of the webview to match this. In a traditional iframe setting, something like iframe-resizer could be used, but we’ve disabled JavaScript. Additionally, we can’t access the contentDocument of the iframe (OOP iframes are always treated as cross-origin iframes, and there’s a shadow root involved too).

The naïve approach is to try using webview.executeJavaScript() directly, however this will give errors from Chrome, as we’ve just told it to disable JavaScript. Makes sense, but also annoying.

Preload scripts do run though. After experimentation with this however, it appears that messaging doesn’t fully work, and DOM events don’t persist after preloading. This means it’s functionally useless for this purpose.

After a tonne of experimentation, it turns out that using contents.executeJavaScriptInIsolatedWorld() works, provided you supply a non-zero world ID. This also has access to the DOM. While you can’t use IPC or messaging from this world, your script can return a value. So, to get the inherent size of the user content, we can run the following in the main process:

app.on( 'web-contents-created', function ( event, contents ) {
    // Calculate height in an isolated world.
    contents.executeJavaScriptInIsolatedWorld( 240, [
        {
            code: 'document.documentElement.scrollHeight',
        }
    ] ).then( function ( height ) {
        // We now have the height.
    } );
} );

As a slight wrinkle to this, you cannot access this from the renderer, only from the main process. Thankfully, you can access the webcontents ID from the renderer, so you can use IPC to link these two together:

// In main:
win.webContents.send( 'webview-height', {
    id: contents.id,
    height,
} );

// In renderer:
ipcRenderer.on( 'webview-height', function ( e, data ) {
    if ( data.id === webview.id ) {
        webview.style.height = data.height;
    }
} );

Your code in practice will likely need to be more complex than this to properly handle timing; you could also use ipcRenderer.invoke with webContents.fromId() to drive this from the renderer instead.

Leave a Reply