Skip to content

Commit

Permalink
feat: Restore webContents navigation history and page state (#45584)
Browse files Browse the repository at this point in the history
* feat: Working navigationHistory.restore with just title/url

Co-authored-by: Felix Rieseberg <fr@makenotion.com>

* feat: Restore page state, too

Co-authored-by: Felix Rieseberg <fr@makenotion.com>

* chore: Docs, lint, tests

Co-authored-by: Felix Rieseberg <fr@makenotion.com>

* Implement feedback

Co-authored-by: Felix Rieseberg <fr@makenotion.com>

* More magic

Co-authored-by: Felix Rieseberg <fr@makenotion.com>

* Make _awaitNextLoad truly private

Co-authored-by: Felix Rieseberg <fr@makenotion.com>

* Implement API group feedback

Co-authored-by: Felix Rieseberg <fr@makenotion.com>

* One more round of feedback

Co-authored-by: Felix Rieseberg <fr@makenotion.com>

---------

Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Felix Rieseberg <fr@makenotion.com>
  • Loading branch information
trop[bot] and felixrieseberg authored Feb 12, 2025
1 parent d04491d commit ddc7afd
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 10 deletions.
19 changes: 19 additions & 0 deletions docs/api/navigation-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,22 @@ Returns `boolean` - Whether the navigation entry was removed from the webContent
#### `navigationHistory.getAllEntries()`

Returns [`NavigationEntry[]`](structures/navigation-entry.md) - WebContents complete history.

#### `navigationHistory.restore(options)`

Restores navigation history and loads the given entry in the in stack. Will make a best effort
to restore not just the navigation stack but also the state of the individual pages - for instance
including HTML form values or the scroll position. It's recommended to call this API before any
navigation entries are created, so ideally before you call `loadURL()` or `loadFile()` on the
`webContents` object.

This API allows you to create common flows that aim to restore, recreate, or clone other webContents.

* `options` Object
* `entries` [NavigationEntry[]](structures/navigation-entry.md) - Result of a prior `getAllEntries()` call
* `index` Integer (optional) - Index of the stack that should be loaded. If you set it to `0`, the webContents will load the first (oldest) entry. If you leave it undefined, Electron will automatically load the last (newest) entry.

Returns `Promise<void>` - the promise will resolve when the page has finished loading the selected navigation entry
(see [`did-finish-load`](web-contents.md#event-did-finish-load)), and rejects
if the page fails to load (see
[`did-fail-load`](web-contents.md#event-did-fail-load)). A noop rejection handler is already attached, which avoids unhandled rejection errors.
3 changes: 3 additions & 0 deletions docs/api/structures/navigation-entry.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@

* `url` string
* `title` string
* `pageState` string (optional) - A base64 encoded data string containing Chromium page state
including information like the current scroll position or form values. It is committed by
Chromium before a navigation event and on a regular interval.
19 changes: 18 additions & 1 deletion docs/tutorial/navigation-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,25 @@ if (navigationHistory.canGoToOffset(2)) {
}
```

## Restoring history

A common flow is that you want to restore the history of a webContents - for instance to implement an "undo close tab" feature. To do so, you can call `navigationHistory.restore({ index, entries })`. This will restore the webContent's navigation history and the webContents location in said history, meaning that `goBack()` and `goForward()` navigate you through the stack as expected.

```js @ts-type={navigationHistory:Electron.NavigationHistory}

const firstWindow = new BrowserWindow()

// Later, you want a second window to have the same history and navigation position
async function restore () {
const entries = firstWindow.webContents.navigationHistory.getAllEntries()
const index = firstWindow.webContents.navigationHistory.getActiveIndex()

const secondWindow = new BrowserWindow()
await secondWindow.webContents.navigationHistory.restore({ index, entries })
}
```

Here's a full example that you can open with Electron Fiddle:

```fiddle docs/fiddles/features/navigation-history
```
34 changes: 29 additions & 5 deletions lib/browser/api/web-contents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as deprecate from '@electron/internal/common/deprecate';
import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';

import { app, ipcMain, session, webFrameMain, dialog } from 'electron/main';
import type { BrowserWindowConstructorOptions, MessageBoxOptions } from 'electron/main';
import type { BrowserWindowConstructorOptions, MessageBoxOptions, NavigationEntry } from 'electron/main';

import * as path from 'path';
import * as url from 'url';
Expand Down Expand Up @@ -343,8 +343,8 @@ WebContents.prototype.loadFile = function (filePath, options = {}) {

type LoadError = { errorCode: number, errorDescription: string, url: string };

WebContents.prototype.loadURL = function (url, options) {
const p = new Promise<void>((resolve, reject) => {
function _awaitNextLoad (this: Electron.WebContents, navigationUrl: string) {
return new Promise<void>((resolve, reject) => {
const resolveAndCleanup = () => {
removeListeners();
resolve();
Expand Down Expand Up @@ -402,7 +402,7 @@ WebContents.prototype.loadURL = function (url, options) {
// the only one is with a bad scheme, perhaps ERR_INVALID_ARGUMENT
// would be more appropriate.
if (!error) {
error = { errorCode: -2, errorDescription: 'ERR_FAILED', url };
error = { errorCode: -2, errorDescription: 'ERR_FAILED', url: navigationUrl };
}
finishListener();
};
Expand All @@ -426,6 +426,10 @@ WebContents.prototype.loadURL = function (url, options) {
this.on('did-stop-loading', stopLoadingListener);
this.on('destroyed', stopLoadingListener);
});
};

WebContents.prototype.loadURL = function (url, options) {
const p = _awaitNextLoad.call(this, url);
// Add a no-op rejection handler to silence the unhandled rejection error.
p.catch(() => {});
this._loadURL(url, options ?? {});
Expand Down Expand Up @@ -609,7 +613,27 @@ WebContents.prototype._init = function () {
length: this._historyLength.bind(this),
getEntryAtIndex: this._getNavigationEntryAtIndex.bind(this),
removeEntryAtIndex: this._removeNavigationEntryAtIndex.bind(this),
getAllEntries: this._getHistory.bind(this)
getAllEntries: this._getHistory.bind(this),
restore: ({ index, entries }: { index?: number, entries: NavigationEntry[] }) => {
if (index === undefined) {
index = entries.length - 1;
}

if (index < 0 || !entries[index]) {
throw new Error('Invalid index. Index must be a positive integer and within the bounds of the entries length.');
}

const p = _awaitNextLoad.call(this, entries[index].url);
p.catch(() => {});

try {
this._restoreHistory(index, entries);
} catch (error) {
return Promise.reject(error);
}

return p;
}
},
writable: false,
enumerable: true
Expand Down
91 changes: 90 additions & 1 deletion shell/browser/api/electron_api_web_contents.cc
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
#include "content/public/browser/keyboard_event_processing_result.h"
#include "content/public/browser/navigation_details.h"
#include "content/public/browser/navigation_entry.h"
#include "content/public/browser/navigation_entry_restore_context.h"
#include "content/public/browser/navigation_handle.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/render_process_host.h"
Expand Down Expand Up @@ -353,14 +354,60 @@ struct Converter<scoped_refptr<content::DevToolsAgentHost>> {

template <>
struct Converter<content::NavigationEntry*> {
static bool FromV8(v8::Isolate* isolate,
v8::Local<v8::Value> val,
content::NavigationEntry** out) {
gin_helper::Dictionary dict;
if (!gin::ConvertFromV8(isolate, val, &dict))
return false;

std::string url_str;
std::string title;
std::string encoded_page_state;
GURL url;

if (!dict.Get("url", &url) || !dict.Get("title", &title))
return false;

auto entry = content::NavigationEntry::Create();
entry->SetURL(url);
entry->SetTitle(base::UTF8ToUTF16(title));

// Handle optional page state
if (dict.Get("pageState", &encoded_page_state)) {
std::string decoded_page_state;
if (base::Base64Decode(encoded_page_state, &decoded_page_state)) {
auto restore_context = content::NavigationEntryRestoreContext::Create();

auto page_state =
blink::PageState::CreateFromEncodedData(decoded_page_state);
if (!page_state.IsValid())
return false;

entry->SetPageState(std::move(page_state), restore_context.get());
}
}

*out = entry.release();
return true;
}

static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,
content::NavigationEntry* entry) {
if (!entry) {
return v8::Null(isolate);
}
gin_helper::Dictionary dict(isolate, v8::Object::New(isolate));
gin_helper::Dictionary dict = gin_helper::Dictionary::CreateEmpty(isolate);
dict.Set("url", entry->GetURL().spec());
dict.Set("title", entry->GetTitleForDisplay());

// Page state saves scroll position and values of any form fields
const blink::PageState& page_state = entry->GetPageState();
if (page_state.IsValid()) {
std::string encoded_data = base::Base64Encode(page_state.ToEncodedData());
dict.Set("pageState", encoded_data);
}

return dict.GetHandle();
}
};
Expand Down Expand Up @@ -2560,6 +2607,47 @@ std::vector<content::NavigationEntry*> WebContents::GetHistory() const {
return history;
}

void WebContents::RestoreHistory(
v8::Isolate* isolate,
gin_helper::ErrorThrower thrower,
int index,
const std::vector<v8::Local<v8::Value>>& entries) {
if (!web_contents()
->GetController()
.GetLastCommittedEntry()
->IsInitialEntry()) {
thrower.ThrowError(
"Cannot restore history on webContents that have previously loaded "
"a page.");
return;
}

auto navigation_entries = std::make_unique<
std::vector<std::unique_ptr<content::NavigationEntry>>>();

for (const auto& entry : entries) {
content::NavigationEntry* nav_entry = nullptr;
if (!gin::Converter<content::NavigationEntry*>::FromV8(isolate, entry,
&nav_entry) ||
!nav_entry) {
// Invalid entry, bail out early
thrower.ThrowError(
"Failed to restore navigation history: Invalid navigation entry at "
"index " +
std::to_string(index) + ".");
return;
}
navigation_entries->push_back(
std::unique_ptr<content::NavigationEntry>(nav_entry));
}

if (!navigation_entries->empty()) {
web_contents()->GetController().Restore(
index, content::RestoreType::kRestored, navigation_entries.get());
web_contents()->GetController().LoadIfNecessary();
}
}

void WebContents::ClearHistory() {
// In some rare cases (normally while there is no real history) we are in a
// state where we can't prune navigation entries
Expand Down Expand Up @@ -4389,6 +4477,7 @@ void WebContents::FillObjectTemplate(v8::Isolate* isolate,
&WebContents::RemoveNavigationEntryAtIndex)
.SetMethod("_getHistory", &WebContents::GetHistory)
.SetMethod("_clearHistory", &WebContents::ClearHistory)
.SetMethod("_restoreHistory", &WebContents::RestoreHistory)
.SetMethod("isCrashed", &WebContents::IsCrashed)
.SetMethod("forcefullyCrashRenderer",
&WebContents::ForcefullyCrashRenderer)
Expand Down
4 changes: 4 additions & 0 deletions shell/browser/api/electron_api_web_contents.h
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ class WebContents final : public ExclusiveAccessContext,
bool RemoveNavigationEntryAtIndex(int index);
std::vector<content::NavigationEntry*> GetHistory() const;
void ClearHistory();
void RestoreHistory(v8::Isolate* isolate,
gin_helper::ErrorThrower thrower,
int index,
const std::vector<v8::Local<v8::Value>>& entries);
int GetHistoryLength() const;
const std::string GetWebRTCIPHandlingPolicy() const;
void SetWebRTCIPHandlingPolicy(const std::string& webrtc_ip_handling_policy);
Expand Down
97 changes: 94 additions & 3 deletions spec/api-web-contents-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,12 +699,14 @@ describe('webContents module', () => {
describe('navigationHistory.getEntryAtIndex(index) API ', () => {
it('should fetch default navigation entry when no urls are loaded', async () => {
const result = w.webContents.navigationHistory.getEntryAtIndex(0);
expect(result).to.deep.equal({ url: '', title: '' });
expect(result.url).to.equal('');
expect(result.title).to.equal('');
});
it('should fetch navigation entry given a valid index', async () => {
await w.loadURL(urlPage1);
const result = w.webContents.navigationHistory.getEntryAtIndex(0);
expect(result).to.deep.equal({ url: urlPage1, title: 'Page 1' });
expect(result.url).to.equal(urlPage1);
expect(result.title).to.equal('Page 1');
});
it('should return null given an invalid index larger than history length', async () => {
await w.loadURL(urlPage1);
Expand Down Expand Up @@ -763,7 +765,10 @@ describe('webContents module', () => {
await w.loadURL(urlPage1);
await w.loadURL(urlPage2);
await w.loadURL(urlPage3);
const entries = w.webContents.navigationHistory.getAllEntries();
const entries = w.webContents.navigationHistory.getAllEntries().map(entry => ({
url: entry.url,
title: entry.title
}));
expect(entries.length).to.equal(3);
expect(entries[0]).to.deep.equal({ url: urlPage1, title: 'Page 1' });
expect(entries[1]).to.deep.equal({ url: urlPage2, title: 'Page 2' });
Expand All @@ -774,6 +779,92 @@ describe('webContents module', () => {
const entries = w.webContents.navigationHistory.getAllEntries();
expect(entries.length).to.equal(0);
});

it('should create a NavigationEntry with PageState that can be serialized/deserialized with JSON', async () => {
await w.loadURL(urlPage1);
await w.loadURL(urlPage2);
await w.loadURL(urlPage3);

const entries = w.webContents.navigationHistory.getAllEntries();
const serialized = JSON.stringify(entries);
const deserialized = JSON.parse(serialized);
expect(deserialized).to.deep.equal(entries);
});
});

describe('navigationHistory.restore({ index, entries }) API', () => {
let server: http.Server;
let serverUrl: string;

before(async () => {
server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/html');
res.end('<html><head><title>Form</title></head><body><form><input type="text" value="value" /></form></body></html>');
});
serverUrl = (await listen(server)).url;
});

after(async () => {
if (server) await new Promise(resolve => server.close(resolve));
server = null as any;
});

it('should restore navigation history with PageState', async () => {
await w.loadURL(urlPage1);
await w.loadURL(urlPage2);
await w.loadURL(serverUrl);

// Fill out the form on the page
await w.webContents.executeJavaScript('document.querySelector("input").value = "Hi!";');

// PageState is committed:
// 1) When the page receives an unload event
// 2) During periodic serialization of page state
// To not wait randomly for the second option, we'll trigger another load
await w.loadURL(urlPage3);

// Save the navigation state
const entries = w.webContents.navigationHistory.getAllEntries();

// Close the window, make a new one
w.close();
w = new BrowserWindow();

const formValue = await new Promise<string>(resolve => {
w.webContents.once('dom-ready', () => resolve(w.webContents.executeJavaScript('document.querySelector("input").value')));

// Restore the navigation history
return w.webContents.navigationHistory.restore({ index: 2, entries });
});

expect(formValue).to.equal('Hi!');
});

it('should handle invalid base64 pageState', async () => {
await w.loadURL(urlPage1);
await w.loadURL(urlPage2);
await w.loadURL(urlPage3);

const brokenEntries = w.webContents.navigationHistory.getAllEntries().map(entry => ({
...entry,
pageState: 'invalid base64'
}));

// Close the window, make a new one
w.close();
w = new BrowserWindow();
await w.webContents.navigationHistory.restore({ index: 2, entries: brokenEntries });

const entries = w.webContents.navigationHistory.getAllEntries();

// Check that we used the original url and titles but threw away the broken
// pageState
entries.forEach((entry, index) => {
expect(entry.url).to.equal(brokenEntries[index].url);
expect(entry.title).to.equal(brokenEntries[index].title);
expect(entry.pageState?.length).to.be.greaterThanOrEqual(100);
});
});
});
});

Expand Down
Loading

0 comments on commit ddc7afd

Please sign in to comment.