feat: Restore webContents navigation history and page state (#45433)

* feat: Working navigationHistory.restore with just title/url

* feat: Restore page state, too

* chore: Docs, lint, tests

* Implement feedback

* More magic

* Make _awaitNextLoad truly private

* Implement API group feedback

* One more round of feedback
This commit is contained in:
Felix Rieseberg 2025-02-11 15:09:38 -08:00 committed by GitHub
parent 6fdfca6e49
commit 9f47c9a051
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 259 additions and 10 deletions

View file

@ -74,3 +74,22 @@ Returns `boolean` - Whether the navigation entry was removed from the webContent
#### `navigationHistory.getAllEntries()` #### `navigationHistory.getAllEntries()`
Returns [`NavigationEntry[]`](structures/navigation-entry.md) - WebContents complete history. 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.

View file

@ -2,3 +2,6 @@
* `url` string * `url` string
* `title` 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.

View file

@ -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: Here's a full example that you can open with Electron Fiddle:
```fiddle docs/fiddles/features/navigation-history ```fiddle docs/fiddles/features/navigation-history
``` ```

View file

@ -8,7 +8,7 @@ import * as deprecate from '@electron/internal/common/deprecate';
import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages'; import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';
import { app, ipcMain, session, webFrameMain, dialog } from 'electron/main'; 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 path from 'path';
import * as url from 'url'; import * as url from 'url';
@ -343,8 +343,8 @@ WebContents.prototype.loadFile = function (filePath, options = {}) {
type LoadError = { errorCode: number, errorDescription: string, url: string }; type LoadError = { errorCode: number, errorDescription: string, url: string };
WebContents.prototype.loadURL = function (url, options) { function _awaitNextLoad (this: Electron.WebContents, navigationUrl: string) {
const p = new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const resolveAndCleanup = () => { const resolveAndCleanup = () => {
removeListeners(); removeListeners();
resolve(); resolve();
@ -402,7 +402,7 @@ WebContents.prototype.loadURL = function (url, options) {
// the only one is with a bad scheme, perhaps ERR_INVALID_ARGUMENT // the only one is with a bad scheme, perhaps ERR_INVALID_ARGUMENT
// would be more appropriate. // would be more appropriate.
if (!error) { if (!error) {
error = { errorCode: -2, errorDescription: 'ERR_FAILED', url }; error = { errorCode: -2, errorDescription: 'ERR_FAILED', url: navigationUrl };
} }
finishListener(); finishListener();
}; };
@ -426,6 +426,10 @@ WebContents.prototype.loadURL = function (url, options) {
this.on('did-stop-loading', stopLoadingListener); this.on('did-stop-loading', stopLoadingListener);
this.on('destroyed', 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. // Add a no-op rejection handler to silence the unhandled rejection error.
p.catch(() => {}); p.catch(() => {});
this._loadURL(url, options ?? {}); this._loadURL(url, options ?? {});
@ -611,7 +615,27 @@ WebContents.prototype._init = function () {
length: this._historyLength.bind(this), length: this._historyLength.bind(this),
getEntryAtIndex: this._getNavigationEntryAtIndex.bind(this), getEntryAtIndex: this._getNavigationEntryAtIndex.bind(this),
removeEntryAtIndex: this._removeNavigationEntryAtIndex.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, writable: false,
enumerable: true enumerable: true

View file

@ -54,6 +54,7 @@
#include "content/public/browser/keyboard_event_processing_result.h" #include "content/public/browser/keyboard_event_processing_result.h"
#include "content/public/browser/navigation_details.h" #include "content/public/browser/navigation_details.h"
#include "content/public/browser/navigation_entry.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/navigation_handle.h"
#include "content/public/browser/render_frame_host.h" #include "content/public/browser/render_frame_host.h"
#include "content/public/browser/render_process_host.h" #include "content/public/browser/render_process_host.h"
@ -363,14 +364,60 @@ struct Converter<scoped_refptr<content::DevToolsAgentHost>> {
template <> template <>
struct Converter<content::NavigationEntry*> { 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, static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,
content::NavigationEntry* entry) { content::NavigationEntry* entry) {
if (!entry) { if (!entry) {
return v8::Null(isolate); 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("url", entry->GetURL().spec());
dict.Set("title", entry->GetTitleForDisplay()); 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(); return dict.GetHandle();
} }
}; };
@ -2572,6 +2619,47 @@ std::vector<content::NavigationEntry*> WebContents::GetHistory() const {
return history; 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() { void WebContents::ClearHistory() {
// In some rare cases (normally while there is no real history) we are in a // In some rare cases (normally while there is no real history) we are in a
// state where we can't prune navigation entries // state where we can't prune navigation entries
@ -4397,6 +4485,7 @@ void WebContents::FillObjectTemplate(v8::Isolate* isolate,
&WebContents::RemoveNavigationEntryAtIndex) &WebContents::RemoveNavigationEntryAtIndex)
.SetMethod("_getHistory", &WebContents::GetHistory) .SetMethod("_getHistory", &WebContents::GetHistory)
.SetMethod("_clearHistory", &WebContents::ClearHistory) .SetMethod("_clearHistory", &WebContents::ClearHistory)
.SetMethod("_restoreHistory", &WebContents::RestoreHistory)
.SetMethod("isCrashed", &WebContents::IsCrashed) .SetMethod("isCrashed", &WebContents::IsCrashed)
.SetMethod("forcefullyCrashRenderer", .SetMethod("forcefullyCrashRenderer",
&WebContents::ForcefullyCrashRenderer) &WebContents::ForcefullyCrashRenderer)

View file

@ -219,6 +219,10 @@ class WebContents final : public ExclusiveAccessContext,
bool RemoveNavigationEntryAtIndex(int index); bool RemoveNavigationEntryAtIndex(int index);
std::vector<content::NavigationEntry*> GetHistory() const; std::vector<content::NavigationEntry*> GetHistory() const;
void ClearHistory(); void ClearHistory();
void RestoreHistory(v8::Isolate* isolate,
gin_helper::ErrorThrower thrower,
int index,
const std::vector<v8::Local<v8::Value>>& entries);
int GetHistoryLength() const; int GetHistoryLength() const;
const std::string GetWebRTCIPHandlingPolicy() const; const std::string GetWebRTCIPHandlingPolicy() const;
void SetWebRTCIPHandlingPolicy(const std::string& webrtc_ip_handling_policy); void SetWebRTCIPHandlingPolicy(const std::string& webrtc_ip_handling_policy);

View file

@ -699,12 +699,14 @@ describe('webContents module', () => {
describe('navigationHistory.getEntryAtIndex(index) API ', () => { describe('navigationHistory.getEntryAtIndex(index) API ', () => {
it('should fetch default navigation entry when no urls are loaded', async () => { it('should fetch default navigation entry when no urls are loaded', async () => {
const result = w.webContents.navigationHistory.getEntryAtIndex(0); 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 () => { it('should fetch navigation entry given a valid index', async () => {
await w.loadURL(urlPage1); await w.loadURL(urlPage1);
const result = w.webContents.navigationHistory.getEntryAtIndex(0); 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 () => { it('should return null given an invalid index larger than history length', async () => {
await w.loadURL(urlPage1); await w.loadURL(urlPage1);
@ -763,7 +765,10 @@ describe('webContents module', () => {
await w.loadURL(urlPage1); await w.loadURL(urlPage1);
await w.loadURL(urlPage2); await w.loadURL(urlPage2);
await w.loadURL(urlPage3); 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.length).to.equal(3);
expect(entries[0]).to.deep.equal({ url: urlPage1, title: 'Page 1' }); expect(entries[0]).to.deep.equal({ url: urlPage1, title: 'Page 1' });
expect(entries[1]).to.deep.equal({ url: urlPage2, title: 'Page 2' }); expect(entries[1]).to.deep.equal({ url: urlPage2, title: 'Page 2' });
@ -774,6 +779,92 @@ describe('webContents module', () => {
const entries = w.webContents.navigationHistory.getAllEntries(); const entries = w.webContents.navigationHistory.getAllEntries();
expect(entries.length).to.equal(0); 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);
});
});
}); });
}); });

View file

@ -87,6 +87,7 @@ declare namespace Electron {
} }
interface WebContents { interface WebContents {
_awaitNextLoad(expectedUrl: string): Promise<void>;
_loadURL(url: string, options: ElectronInternal.LoadURLOptions): void; _loadURL(url: string, options: ElectronInternal.LoadURLOptions): void;
getOwnerBrowserWindow(): Electron.BrowserWindow | null; getOwnerBrowserWindow(): Electron.BrowserWindow | null;
getLastWebPreferences(): Electron.WebPreferences | null; getLastWebPreferences(): Electron.WebPreferences | null;
@ -115,6 +116,7 @@ declare namespace Electron {
_goToIndex(index: number): void; _goToIndex(index: number): void;
_removeNavigationEntryAtIndex(index: number): boolean; _removeNavigationEntryAtIndex(index: number): boolean;
_getHistory(): Electron.NavigationEntry[]; _getHistory(): Electron.NavigationEntry[];
_restoreHistory(index: number, entries: Electron.NavigationEntry[]): void
_clearHistory():void _clearHistory():void
canGoToIndex(index: number): boolean; canGoToIndex(index: number): boolean;
destroy(): void; destroy(): void;