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.

Editing Commits With Git

As developers who use GitHub, we use and love pull requests. But what happens when you only want part of a pull request?

Let’s say, for example, that we have a pull request with three commits. Unfortunately, the pull request’s author accidentally used spaces instead of tabs in the second commit, so we want to fix this up before we pull it in.

The main tool we’re going to use here is interactive rebasing. Rebasing is a way to rewrite your git repository’s history, so you should never use it on code that you’ve pushed up. However, since we’re bringing in new code, it’s perfectly fine to do this. The PR’s author may need to reset their repository, but their changes should be on separate branches to avoid this.

So, let’s get started. First, follow GitHub’s first two instructions to merge (click the little i next to the Merge Pull Request button):

git checkout -b otheruser-master master
git pull git@github.com:otheruser/myproject.git master

Now that we have our repository up-to-date with theirs, it’s time to rewrite history. We want to rebase (rewrite) the last three commits, so we specify the range as HEAD~3:

git rebase -i HEAD~3

This puts us into the editor to change the rebase history. It should look like this:

pick c6ffde3 Point out how obvious it is
pick 9686795 We're proud of our little file!
pick a712c2c Add another file.

There are a number of commands we can pick here. For us, we want to leave the first and last unchanged, so we’ll keep those as pick. However, we want to edit the second commit, so we’ll change pick to edit there. Saving the file then spits us back out to the terminal. It’ll also tell us that when we’re done, we should run git commit --amend and git rebase --continue.

Internally, what this does is replay all the commits up until the one with edit. After it replays that commit, it pauses and waits for us to continue it.

Now, we can go and edit the file. Make the changes you need here, then as it told us, it’s time to make amends:

git commit --amend

This merges the changes you just made with the previous commit (the one with the misspelling) into a new commit. Once we’ve done that, we need to continue the rebase, as all the commits after this one have to be rewritten to point to our new one in the history.

git rebase --continue

Our history rewriting is now done! It’s now time to merge it back in to master, push up our changes and close the pull request. First we’ll need to switch back to master, then we can merge and push.

git checkout master
git merge otheruser-master master
git push

Congratulations, you just (re)wrote history!


For those of you who want to try this, I’ve made a test repository for you to work on. Try it out and see if you can fix it yourself!

Thanks to Japh for asking the question that inspired this post!