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:
parent
6fdfca6e49
commit
9f47c9a051
8 changed files with 259 additions and 10 deletions
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
@ -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';
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
};
|
||||
|
@ -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 ?? {});
|
||||
|
@ -611,7 +615,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
|
||||
|
|
|
@ -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"
|
||||
|
@ -363,14 +364,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();
|
||||
}
|
||||
};
|
||||
|
@ -2572,6 +2619,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
|
||||
|
@ -4397,6 +4485,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)
|
||||
|
|
|
@ -219,6 +219,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);
|
||||
|
|
|
@ -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);
|
||||
|
@ -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' });
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
2
typings/internal-electron.d.ts
vendored
2
typings/internal-electron.d.ts
vendored
|
@ -87,6 +87,7 @@ declare namespace Electron {
|
|||
}
|
||||
|
||||
interface WebContents {
|
||||
_awaitNextLoad(expectedUrl: string): Promise<void>;
|
||||
_loadURL(url: string, options: ElectronInternal.LoadURLOptions): void;
|
||||
getOwnerBrowserWindow(): Electron.BrowserWindow | null;
|
||||
getLastWebPreferences(): Electron.WebPreferences | null;
|
||||
|
@ -115,6 +116,7 @@ declare namespace Electron {
|
|||
_goToIndex(index: number): void;
|
||||
_removeNavigationEntryAtIndex(index: number): boolean;
|
||||
_getHistory(): Electron.NavigationEntry[];
|
||||
_restoreHistory(index: number, entries: Electron.NavigationEntry[]): void
|
||||
_clearHistory():void
|
||||
canGoToIndex(index: number): boolean;
|
||||
destroy(): void;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue